Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Node.js Interview Questions (55+ Detailed Q&A)

1. Runtime & Core Architecture

Answer: No, Node.js is a Runtime Environment for executing JavaScript outside the browser — it is not a framework, not a language, and not a web server (though it can create one).Architecture stack:
  • Chrome V8 Engine: Compiles JS directly to machine code (no interpreter step). Uses JIT (Just-In-Time) compilation with TurboFan optimizing compiler and Sparkplug baseline compiler.
  • Libuv: A C library that provides the event loop, async I/O (file system, DNS, network), a thread pool (default 4 threads), and cross-platform abstractions over OS-level async primitives (epoll on Linux, kqueue on macOS, IOCP on Windows).
  • C++ bindings: Bridge between JS and native OS APIs (crypto, zlib, http_parser).
Key mental model: Node is a single-threaded orchestrator. Your JS runs on one thread, but the actual I/O work is delegated to the OS kernel or Libuv’s thread pool. This is what makes it non-blocking.What interviewers are really testing: Do you understand that Node is not “just JavaScript”? Can you articulate the relationship between V8, Libuv, and the event loop? Candidates who say “Node.js is a JavaScript framework” are an instant red flag.Red flag answer: “Node.js is a server-side framework for building web apps.” This conflates the runtime with Express/Koa frameworks and shows surface-level understanding.Follow-up:
  • “If V8 compiles JS to machine code, where does the bytecode step fit in? What is Ignition vs TurboFan?”
  • “Can you explain what Libuv’s thread pool is used for vs what goes directly to the OS kernel?”
  • “Why would Node choose to be single-threaded instead of multi-threaded like Java’s Tomcat?”
Answer: Node uses a Single Main Thread for JS execution (the Event Loop), but this is only half the story.How it actually scales:
  1. Event Loop (single thread): Orchestrates callbacks, runs your JS code, and dispatches I/O requests.
  2. OS Kernel Async Primitives: Network I/O (TCP sockets, HTTP) goes directly to the kernel via epoll/kqueue/IOCP — zero threads needed. This is how Node handles 10k+ concurrent connections on a single process.
  3. Libuv Thread Pool (default 4 threads, max 1024 via UV_THREADPOOL_SIZE): Used for operations the OS cannot do asynchronously — file system operations, DNS lookups (dns.lookup, not dns.resolve), and some crypto operations.
The C10K advantage: Traditional thread-per-request models (Apache) need ~2MB per thread. 10k connections = 20GB RAM just for threads. Node handles 10k connections with a single thread + kernel event notifications, using ~50-100MB total.Real-world example: A chat server handling 50k concurrent WebSocket connections. Each connection is just a file descriptor in the kernel’s epoll set — no thread allocation per connection.What interviewers are really testing: Can you distinguish between “single-threaded JS” and “Node uses threads behind the scenes”? Strong candidates know about the Libuv thread pool, UV_THREADPOOL_SIZE, and which operations use threads vs kernel async.Red flag answer: “Node is single-threaded so it can only do one thing at a time.” This misunderstands the delegation model entirely.Follow-up:
  • “If the thread pool default is 4, what happens when you have 100 concurrent fs.readFile calls? Does the 101st block?”
  • “When would you increase UV_THREADPOOL_SIZE and what’s the cost of making it too large?”
  • “How does the Cluster module change this picture? What about Worker Threads?”
Answer: The event loop is not a single queue — it is a series of phases, each with its own FIFO queue. Understanding this is critical for debugging timing issues.The 6 Phases (in order):
  1. Timers: Execute callbacks from setTimeout and setInterval whose threshold has elapsed. Note: timers are not guaranteed to fire at the exact time — they fire as soon as possible after the threshold, in this phase.
  2. Pending Callbacks: Execute I/O callbacks deferred from the previous loop iteration (e.g., TCP errors like ECONNREFUSED).
  3. Idle/Prepare: Internal use only by Libuv. You cannot interact with this phase.
  4. Poll: This is the heart of the event loop. It retrieves new I/O events from the kernel, executes their callbacks (file read complete, socket data received, etc.). If the poll queue is empty, it will block here waiting for new events (up to a calculated timeout based on pending timers).
  5. Check: setImmediate callbacks execute here. This is specifically designed to run callbacks after the poll phase completes.
  6. Close Callbacks: socket.on('close'), cleanup handlers.
Microtask Queues (run between every phase transition):
  • process.nextTick queue (higher priority — runs first)
  • Promise microtask queue (.then, .catch, .finally)
Execution Order Example:
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));

console.log('sync');

// Output:
// sync
// nextTick
// promise
// timeout (or immediate, depends on timing)
// immediate (or timeout)
Why Order Varies: setTimeout(fn, 0) actually means setTimeout(fn, 1) internally (minimum 1ms). If the event loop enters the timers phase in under 1ms, the timer hasn’t elapsed yet, so setImmediate (check phase) fires first. If it takes longer than 1ms to enter timers, setTimeout fires first. This is non-deterministic in the main module but deterministic inside an I/O callback (setImmediate always fires first there, because after I/O you’re in the poll phase, and check phase comes next).
// DETERMINISTIC: Inside I/O callback, setImmediate always first
const fs = require('fs');
fs.readFile(__filename, () => {
    setTimeout(() => console.log('timeout'), 0);
    setImmediate(() => console.log('immediate'));
});
// Always: immediate, timeout
What interviewers are really testing: Can you explain the phases without just listing names? Do you know why the poll phase blocks, what microtasks are, and the setTimeout(0) vs setImmediate indeterminacy? This question separates candidates who have read the docs from those who have debugged timing bugs.Red flag answer: “The event loop just processes callbacks in order” or listing phases without understanding what the poll phase actually does.Follow-up:
  • “What happens if you schedule a process.nextTick inside a process.nextTick recursively? How does that differ from recursive setImmediate?”
  • “If I have a setTimeout(fn, 5) and the poll phase is blocked waiting for I/O for 10ms, does the timer fire late?”
  • “In Node 11+, what changed about microtask execution order between phases vs inside a single phase?”
Answer: These are two fundamentally different scheduling mechanisms with different positions in the event loop.process.nextTick:
  • Runs immediately after the current operation completes, before the event loop continues to the next phase.
  • Part of the microtask queue (along with Promise callbacks), but nextTick has higher priority than Promises.
  • Starvation risk: Recursive nextTick will starve I/O because the event loop never advances past the microtask checkpoint.
  • Use case: Emitting events after a constructor returns (so the caller can attach listeners), ensuring a callback fires before any I/O.
setImmediate:
  • Runs in the Check phase of the event loop (after the poll phase).
  • Cannot starve I/O: Even recursive setImmediate allows the event loop to process I/O, timers, etc. between iterations.
  • Use case: Breaking up CPU work so I/O can still be processed; preferred for recursive scheduling.
Starvation Example:
// BAD: Infinite nextTick loop blocks everything!
function recursiveNextTick() {
    process.nextTick(recursiveNextTick);
}
recursiveNextTick();
// Event loop NEVER proceeds to other phases!
// setTimeout callbacks never fire, I/O never processes.

// GOOD: setImmediate allows other phases to run
function recursiveImmediate() {
    setImmediate(recursiveImmediate);
}
recursiveImmediate();
// Event loop can still handle I/O, timers, etc.
Real-world war story: A production service had a middleware that called process.nextTick in a tight loop to “batch” database operations. Under load, this starved the HTTP server’s ability to accept new connections. The process appeared alive (health check port was open) but returned zero responses. Switching to setImmediate fixed the issue instantly.The naming is backwards: process.nextTick fires before the next tick of the event loop, and setImmediate fires on the next tick (check phase). The Node.js team has acknowledged this is confusing but cannot change it due to backward compatibility.What interviewers are really testing: Do you understand microtask starvation? Can you articulate when each is appropriate? Have you ever been bitten by nextTick in production?Red flag answer: “They’re basically the same thing” or “I always use setTimeout(fn, 0) instead.”Follow-up:
  • “If you needed to guarantee a callback fires before any pending I/O but after the current synchronous code, which would you use and why?”
  • “How does queueMicrotask() relate to process.nextTick? Which runs first?”
  • “In a real application, give me a concrete example where using nextTick instead of setImmediate caused a bug.”
Answer: V8 divides its heap into New Space (short-lived objects, ~1-8MB) and Old Space (long-lived objects). The total heap limit defaults to approximately 1.5GB on 64-bit systems and ~700MB on 32-bit systems.Adjusting the limit:
node --max-old-space-size=4096 app.js   # 4GB old space
node --max-semi-space-size=64 app.js     # 64MB semi-space (new space half)
Why the limit exists: V8’s garbage collector must pause JS execution during major GC (mark-sweep-compact). Larger heaps mean longer GC pauses. At 4GB, you might see 500ms+ GC pauses, which is catastrophic for an HTTP server handling real-time requests.Real-world considerations:
  • Container environments: If your container has 2GB RAM and you set --max-old-space-size=4096, Node will be OOM-killed by the kernel. Always set the flag to ~75% of your container’s memory limit (leave room for native memory, Buffers, thread stacks).
  • Buffers and native memory: Buffer allocations live outside the V8 heap. A process can use 500MB of Buffers while reporting only 100MB heap usage. process.memoryUsage() shows both heapUsed and external (C++ objects including Buffers).
What interviewers are really testing: Do you know about memory regions beyond the heap? Can you reason about memory in containerized deployments?Red flag answer: “Just increase the memory flag if you run out” without considering GC pause impact or container limits.Follow-up:
  • “What’s the difference between heapUsed, heapTotal, rss, and external in process.memoryUsage()?”
  • “How would you size the --max-old-space-size flag for a Node process running in a Kubernetes pod with a 1GB memory limit?”
  • “What happens to GC pause times as you increase heap size? How does this affect P99 latency?”
Answer: V8 uses a Generational Garbage Collector based on the observation that most objects die young (the “generational hypothesis”).Heap Regions:
  • New Space (Young Generation): Split into two semi-spaces (~1-8MB each). New objects are allocated here.
  • Old Space (Old Generation): Objects that survive two GC cycles in New Space get promoted here.
  • Large Object Space: Objects too large for New Space go directly here.
  • Code Space: JIT-compiled code.
  • Map Space: Hidden class (Shape/Map) metadata.
GC Algorithms:
  1. Scavenge (Minor GC): Runs on New Space. Uses Cheney’s algorithm — copies live objects from one semi-space to the other, then clears the old semi-space. Very fast (~1-5ms) because New Space is small. Objects surviving 2 scavenges get promoted to Old Space.
  2. Mark-Sweep-Compact (Major GC): Runs on Old Space. Three stages — Mark (walk the object graph from roots, mark reachable objects), Sweep (free unmarked objects), Compact (defragment memory). This is the expensive one (50-500ms+) and causes “stop-the-world” pauses.
  3. Incremental Marking: V8 breaks the marking phase into small chunks interleaved with JS execution (increments of ~5ms) to reduce max pause time.
  4. Concurrent Sweeping/Compaction: V8 runs sweeping and some compaction on background threads while JS continues executing.
Orinoco (V8’s modern GC): Combines concurrent marking, parallel scavenging, and concurrent sweeping to minimize main-thread pauses. In practice, most GC pauses in modern Node (v18+) are under 10ms.Monitoring GC in production:
node --trace-gc app.js        # Log GC events
node --expose-gc app.js       # Allow manual GC via global.gc()
What interviewers are really testing: Can you explain why generational GC works (most objects die young)? Do you know the difference between minor and major GC? Can you reason about GC impact on latency?Red flag answer: “V8 handles garbage collection automatically, you don’t need to worry about it.” In production at scale, GC is one of the top causes of latency spikes.Follow-up:
  • “You see a Node process with 2GB heap and periodic 200ms latency spikes. How do you determine if GC is the cause?”
  • “What is an ‘old space expansion’ and how does it differ from a normal major GC?”
  • “How do closures and event listeners contribute to objects being unintentionally promoted to old space?”
Answer: Node.js provides several global objects and module-scoped variables (which look global but are not truly on the global object):Truly global (on the global object):
  • global itself (equivalent to window in browsers, or globalThis in ES2020+)
  • process — the current Node process (PID, env vars, stdin/stdout, exit codes, memory usage)
  • Buffer — binary data handling
  • console — logging
  • setTimeout, setInterval, setImmediate, queueMicrotask
Module-scoped (injected by the CommonJS module wrapper, NOT on global):
  • __dirname — absolute path of the directory containing the current file
  • __filename — absolute path of the current file
  • require — the module loader function
  • module — reference to the current module
  • exports — shorthand for module.exports
The module wrapper: Every CommonJS file is wrapped in:
(function(exports, require, module, __filename, __dirname) {
    // Your code here
});
This is why __dirname is not available in ESM modules — use import.meta.url with fileURLToPath instead.ESM equivalents:
// CommonJS
const dir = __dirname;

// ESM
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
What interviewers are really testing: Do you know the difference between truly global objects and module-scoped variables? Can you explain the CJS module wrapper?Red flag answer: Listing __dirname as a global object without knowing it’s module-scoped, or not knowing the ESM equivalents.Follow-up:
  • “Why is __dirname not available in ES modules? How do you get the equivalent?”
  • “What is globalThis and why was it introduced?”
  • “If you set global.myVar = 42 in one module, can another module access it? What are the dangers of this?”
Answer: These are two fundamentally different module systems with different loading semantics.CommonJS (CJS):
  • require() / module.exports
  • Synchronous loading — require blocks until the module is loaded and evaluated.
  • Dynamic — you can require() inside an if block or compute the module path at runtime.
  • Loads a copy (cached after first load in require.cache).
  • Circular dependency handling: Returns a partially filled module.exports (whatever was exported so far at the point of circular reference). This can cause subtle bugs where you get undefined for properties that haven’t been assigned yet.
  • Default in Node (.js files without "type": "module" in package.json).
ES Modules (ESM):
  • import / export
  • Asynchronous loading — parsed statically, loaded in parallel.
  • Static — imports must be at the top level (enables tree-shaking and static analysis).
  • Live bindings — importing a value gives you a live reference to the exporting module’s binding. If the export changes, the import sees the new value.
  • Circular dependency handling: Due to live bindings, circular deps work more predictably (but you can still access uninitialized bindings if the exporting module hasn’t run that code yet).
  • Enabled via "type": "module" in package.json or .mjs extension.
Interop gotchas:
  • CJS can require() CJS. ESM can import ESM.
  • ESM can import CJS (the module.exports object becomes the default export).
  • CJS cannot require() ESM directly (must use dynamic import() which returns a Promise).
  • __dirname, require.cache, module are not available in ESM.
What interviewers are really testing: Do you understand the semantic differences (sync vs async, copy vs live binding), not just the syntax? Can you navigate the interop challenges?Red flag answer: “CJS uses require, ESM uses import, they’re basically the same.”Follow-up:
  • “What is a ‘live binding’ in ESM and how does it differ from CJS’s value copy?”
  • “You have a library that only ships CJS. How do you use it in an ESM project? What about the reverse?”
  • “What are the implications of CJS’s synchronous loading for server startup time with hundreds of modules?”
Answer: This trips up many developers. The key insight: exports is just a convenience reference that initially points to the same object as module.exports.
// Internally, at module start:
var module = { exports: {} };
var exports = module.exports; // Same reference!
What works:
exports.a = 1;        // module.exports is { a: 1 } -- works!
exports.b = 2;        // module.exports is { a: 1, b: 2 } -- works!
What breaks:
exports = { a: 1 };   // BREAKS! exports now points to a NEW object.
                       // module.exports is still {} (empty).
                       // The require() caller gets {}
Why it breaks: require() always returns module.exports, never exports. When you reassign exports, you break the reference but module.exports is unchanged.Rule: If you are assigning an entirely new object, class, or function as the module’s export, always use module.exports:
// Exporting a class
module.exports = class MyService { /* ... */ };

// Exporting a function
module.exports = function handler(req, res) { /* ... */ };
What interviewers are really testing: Do you understand JavaScript reference semantics? This is really a question about how object references work.Red flag answer: “I just always use module.exports and never use exports” — this works in practice but shows you don’t understand why.Follow-up:
  • “Can you draw a diagram showing what happens to the reference when you do exports = { a: 1 } vs exports.a = 1?”
  • “In ESM, does this same confusion exist? Why or why not?”
Answer: Node’s Achilles’ heel: CPU-bound work blocks the event loop, making the entire server unresponsive. A single 100ms CPU computation means every other request waits 100ms.Solutions (ordered by preference for most cases):
  1. Worker Threads (worker_threads module):
    • True parallel JS execution with shared memory (SharedArrayBuffer) or message passing.
    • Same process, lighter than child processes. Shares the V8 isolate’s native addons.
    • Best for: image processing, data parsing, cryptographic operations.
    • Gotcha: Each worker has its own V8 instance and event loop, so memory overhead is ~5-10MB per worker.
  2. Child Process (child_process.fork):
    • Spawns an entirely new Node.js process with its own V8 instance and memory space.
    • Communication via IPC (inter-process communication) channel.
    • Best for: isolating untrusted code, workloads that might crash (OOM), or when you need full process isolation.
    • Gotcha: Higher overhead (~30MB per process), slower IPC than shared memory.
  3. Offload to specialized service:
    • Send CPU work to a Go/Rust microservice or a job queue (Bull/BullMQ with Redis, RabbitMQ).
    • Best for: video transcoding, ML inference, PDF generation. Let Node do what it’s good at (I/O orchestration).
  4. Native Addons (N-API/NAPI):
    • Write the CPU-intensive function in C/C++ or Rust (via neon) and call it from Node.
    • Best for: tight loops, numerical computation, when you need both speed and integration.
How to detect Event Loop blockage:
// Simple event loop lag monitor
let lastCheck = Date.now();
setInterval(() => {
    const now = Date.now();
    const lag = now - lastCheck - 1000; // Expected 1000ms interval
    if (lag > 50) console.warn(`Event loop lag: ${lag}ms`);
    lastCheck = now;
}, 1000);
What interviewers are really testing: Do you know why CPU tasks are problematic in Node, and can you pick the right solution based on the constraints? A staff-level answer includes trade-offs between the approaches.Red flag answer: “Just use async/await” — async/await does NOT help with CPU-bound work. await cpuIntensiveFunction() still blocks the event loop if the function is synchronous.Follow-up:
  • “What’s the difference between worker_threads and child_process.fork in terms of memory isolation, startup cost, and communication overhead?”
  • “If a CPU task takes 50ms, is that enough to worry about? How do you decide the threshold?”
  • “How would you implement a worker thread pool to process image thumbnails? What happens if all workers are busy?“

2. Streams, Buffers, I/O

Answer: A Buffer is a fixed-size chunk of memory allocated outside the V8 heap (on the C++ side via Libuv). It represents raw binary data — think of it as a byte array like Uint8Array (in fact, Buffer extends Uint8Array since Node 4).Why Buffers exist: JavaScript strings are UTF-16 encoded and immutable. When dealing with file I/O, network packets, or binary protocols (images, video, protobuf), you need raw byte manipulation — that’s what Buffers provide.Key operations:
// Creation (multiple ways)
const buf1 = Buffer.from('hello', 'utf8');     // From string
const buf2 = Buffer.alloc(1024);                // Zero-filled (safe)
const buf3 = Buffer.allocUnsafe(1024);          // Uninitialized (fast but dangerous)
const buf4 = Buffer.from([0x48, 0x65, 0x6c]);  // From byte array

// Encoding conversions
buf1.toString('base64');     // 'aGVsbG8='
buf1.toString('hex');        // '68656c6c6f'
Buffer.alloc vs Buffer.allocUnsafe: allocUnsafe skips zero-filling the memory — it’s faster but may contain leftover data from previous allocations (a security risk if the Buffer is sent to a client without being fully written). Use alloc unless you are immediately overwriting every byte and need the performance.Memory implications: Buffers are tracked by V8’s GC (the JS object is on the heap) but the actual data lives in native memory. This means process.memoryUsage().external increases, not heapUsed. A process can appear to have low heap usage while consuming gigabytes of native memory via Buffers.What interviewers are really testing: Do you understand where Buffers live in memory, why they exist separate from strings, and the security implications of allocUnsafe?Red flag answer: “Buffers are just arrays for storing data” — misses the native memory, encoding, and security aspects.Follow-up:
  • “When would you use Buffer.allocUnsafe over Buffer.alloc? What’s the risk?”
  • “If you have a Buffer containing a user’s password, how do you securely clear it from memory? Can you even do that reliably in Node?”
  • “What’s the relationship between Buffer and Uint8Array? Can you use a Buffer anywhere you’d use a typed array?”
Answer: Streams are Node’s abstraction for handling flowing data — they let you process data piece-by-piece instead of loading everything into memory at once.The 4 Types:
  1. Readable: Produces data. Examples: fs.createReadStream, http.IncomingMessage (request body), process.stdin. Has two modes: flowing (data events, automatic) and paused (must call .read() manually).
  2. Writable: Consumes data. Examples: fs.createWriteStream, http.ServerResponse, process.stdout. Returns false from .write() when its internal buffer is full (backpressure signal).
  3. Duplex: Both Readable and Writable, with independent buffers. Examples: TCP sockets (net.Socket), WebSocket connections. The read and write sides operate independently.
  4. Transform: A special Duplex where the output is a transformation of the input. The read and write sides are connected. Examples: zlib.createGzip() (compress), crypto.createCipheriv() (encrypt), CSV parsers. Data goes in one side, gets modified, comes out the other.
Object Mode: By default, streams handle Buffer/string data. In object mode (objectMode: true), streams can handle any JS value. Used in database cursors, JSON parsers, etc.The pipeline function (preferred over .pipe()):
const { pipeline } = require('stream/promises');

await pipeline(
    fs.createReadStream('input.csv'),
    csvParser,
    transformStream,
    fs.createWriteStream('output.json')
);
// Automatically handles errors and cleanup!
What interviewers are really testing: Can you explain when to use each type, the difference between Duplex and Transform, and do you know about flowing vs paused mode?Red flag answer: Only knowing Readable and Writable, or not knowing that Transform is a subtype of Duplex.Follow-up:
  • “What is the difference between Duplex and Transform? Can you give an example where you’d use Duplex but not Transform?”
  • “What are the two modes of a Readable stream? How do you switch between them?”
  • “Why is stream.pipeline() preferred over .pipe() chaining?”
Answer: Backpressure is the mechanism that prevents a fast producer from overwhelming a slow consumer. Without it, data accumulates in memory and you get OOM crashes.How it works mechanically:
  1. Readable stream pushes data to the Writable stream via .write().
  2. .write() returns false when the Writable’s internal buffer exceeds highWaterMark (default 16KB for Buffer streams, 16 objects for object mode).
  3. The Readable must stop reading (.pause() in flowing mode, stop calling .push() in custom streams).
  4. When the Writable’s buffer drains, it emits a 'drain' event.
  5. The Readable resumes.
.pipe() handles this automatically:
readable.pipe(writable);
// Internally: pauses readable when writable signals backpressure,
// resumes on drain. Handles error propagation (partially).
The problem with .pipe(): It does NOT properly propagate errors through the chain or clean up streams on failure. A broken stream in a pipe chain can cause memory leaks.Solution — use pipeline:
const { pipeline } = require('stream');

pipeline(
    readableStream,
    transformStream,
    writableStream,
    (err) => {
        if (err) console.error('Pipeline failed:', err);
        else console.log('Pipeline succeeded');
    }
);
Real-world war story: A file upload service used .pipe() without backpressure awareness. Users uploading over a fast network to a slow disk caused Node’s memory to spike to 8GB before the process was OOM-killed. The fix: switching to pipeline and setting appropriate highWaterMark values.What interviewers are really testing: Do you understand why backpressure exists, not just how to use .pipe()? Can you explain the highWaterMark and the drain event?Red flag answer: “Just use .pipe() and it handles everything.” This ignores error handling and shows no understanding of the underlying mechanism.Follow-up:
  • “What happens if you ignore the false return value from .write() and keep writing?”
  • “How would you implement custom backpressure in a Transform stream?”
  • “What is highWaterMark and how do you decide what value to set?”
Answer: This is the fundamental question of buffering vs streaming in Node.fs.readFile:
  • Reads the entire file into a single Buffer in memory.
  • Simple API: const data = await fs.promises.readFile('file.txt').
  • Memory usage = file size. A 2GB file requires 2GB of RAM.
  • Use for: small files (config, JSON, templates) — generally files under 10-50MB.
fs.createReadStream:
  • Reads in chunks (default 64KB each, configurable via highWaterMark).
  • Memory usage is constant (~64KB buffer) regardless of file size.
  • Must process data in streaming fashion (events or pipe).
  • Use for: large files, file uploads/downloads, anything where you’re transforming and forwarding data.
The numbers that matter:
// Reading a 1GB file:
// readFile: ~1GB RAM, ~2-3 seconds to start processing (must read entire file first)
// createReadStream: ~64KB RAM, starts processing within milliseconds

// Reading a 10KB config file:
// readFile: simpler code, negligible memory
// createReadStream: unnecessary complexity for this size
The streaming pipeline for file processing:
const { pipeline } = require('stream/promises');
const fs = require('fs');
const zlib = require('zlib');

// Compress a 5GB log file with constant memory usage
await pipeline(
    fs.createReadStream('huge.log'),
    zlib.createGzip(),
    fs.createWriteStream('huge.log.gz')
);
What interviewers are really testing: Do you default to streaming for large data? This reveals whether you’ve built systems that handle real-world file sizes.Red flag answer: “I always use readFile because it’s simpler.” This works until someone uploads a 500MB file and your server crashes.Follow-up:
  • “You’re building an API that accepts CSV uploads and inserts rows into a database. Walk me through how you’d do this for a 2GB file.”
  • “What is the highWaterMark option on createReadStream and when would you tune it?”
  • “How does fs.promises.readFile differ from fs.readFileSync in terms of event loop impact?”
Answer: EventEmitter is the pub/sub backbone of Node.js. Almost every core module (Streams, HTTP Server, Process) inherits from it.
const { EventEmitter } = require('events');
const emitter = new EventEmitter();

// Subscribe
emitter.on('order:created', (order) => {
    console.log('Processing order:', order.id);
});

// Emit
emitter.emit('order:created', { id: 123, total: 49.99 });
Key methods:
  • .on(event, listener) — subscribe (alias: .addListener)
  • .once(event, listener) — subscribe, auto-remove after first call
  • .off(event, listener) — unsubscribe (alias: .removeListener)
  • .emit(event, ...args) — fire synchronously (listeners run in order of registration)
  • .listenerCount(event) — how many listeners for an event
  • .removeAllListeners(event?) — nuclear option
Critical detail — synchronous execution: .emit() calls all listeners synchronously in the order they were added. This means a slow listener blocks subsequent listeners and the caller.Memory leak warning: By default, Node warns if you add more than 10 listeners to a single event (MaxListenersExceededWarning). This usually indicates a bug (adding a listener on every request without removing it).
// Increase limit (per emitter)
emitter.setMaxListeners(50);

// Set globally (use carefully)
require('events').defaultMaxListeners = 20;
Production pattern — typed events:
class OrderService extends EventEmitter {
    async create(data) {
        const order = await db.orders.create(data);
        this.emit('created', order);      // Decouple side effects
        return order;
    }
}

const service = new OrderService();
service.on('created', sendConfirmationEmail);
service.on('created', updateInventory);
service.on('created', notifyWarehouse);
What interviewers are really testing: Do you know that emit is synchronous? Can you articulate the memory leak risk? Do you use EventEmitter for application-level decoupling?Red flag answer: “EventEmitter is just for listening to clicks” — conflating with browser events, or not knowing it’s the foundation of Node I/O.Follow-up:
  • “If emit is synchronous, what happens if one listener throws an error? Do other listeners still run?”
  • “You see MaxListenersExceededWarning in your logs. What’s your debugging approach?”
  • “When would you use EventEmitter vs a message queue like Redis Pub/Sub?”
Answer: Node provides two last-resort safety nets for unhandled errors. Getting this wrong can cause silent data corruption.process.on('uncaughtException'):
  • Fires when a synchronous error is thrown and not caught by any try/catch.
  • Critical rule: After an uncaught exception, the process is in an undefined state. Sockets may be half-written, database transactions may be uncommitted, in-memory state may be inconsistent.
  • Best practice: Log the error, flush logs, and exit the process. Let your process manager (PM2, systemd, Kubernetes) restart it.
process.on('uncaughtException', (err, origin) => {
    logger.fatal({ err, origin }, 'Uncaught exception -- shutting down');
    // Give logger time to flush
    setTimeout(() => process.exit(1), 1000);
});
process.on('unhandledRejection'):
  • Fires when a Promise is rejected and no .catch() or try/catch (in async/await) handles it.
  • Since Node 15+, unhandled rejections throw by default (same as uncaught exceptions). In older versions, they just logged a warning.
process.on('unhandledRejection', (reason, promise) => {
    logger.fatal({ reason }, 'Unhandled rejection -- shutting down');
    setTimeout(() => process.exit(1), 1000);
});
The “just catch it and continue” anti-pattern:
// TERRIBLE: Swallowing the error and continuing
process.on('uncaughtException', (err) => {
    console.error(err); // Log and... keep running? 
    // NO! State is corrupted. You might serve wrong data to users.
});
Defense in depth (what production services actually do):
  1. Wrap all async route handlers with error-catching middleware.
  2. Use domain (deprecated) or AsyncLocalStorage for request-scoped error context.
  3. Set process.on('uncaughtException') and process.on('unhandledRejection') as the last safety net — log and exit.
  4. Run behind PM2/Kubernetes that auto-restarts crashed processes.
What interviewers are really testing: Do you understand that uncaughtException means the process should die? Or do you treat it as a global catch-all?Red flag answer: “I use process.on('uncaughtException') to catch all errors so the server never goes down.” This is actively dangerous — it means you’re serving requests with potentially corrupted state.Follow-up:
  • “Why is it dangerous to continue running after an uncaught exception? Give a concrete scenario.”
  • “How does Express’s error-handling middleware relate to uncaughtException? If an error is caught by Express, does uncaughtException fire?”
  • “What changed in Node 15 regarding unhandled Promise rejections?”
Answer: Node provides both sync and async versions of most fs operations. Knowing when to use each is essential.fs.readFileSync (blocking):
  • Blocks the event loop until the file is fully read.
  • No other request, timer, or I/O callback can execute during this time.
  • On a server handling 1000 req/s, a 10ms sync read means 10ms of total blockage — ~10 requests delayed.
When sync is acceptable:
  • At startup before the server starts listening: loading config files, reading TLS certificates, parsing environment.
  • CLI tools that are not serving concurrent requests.
  • Build scripts and tooling.
When sync is a bug:
  • Inside any request handler, middleware, or event callback.
  • Inside any setInterval or recurring timer.
  • Anywhere after server.listen() is called.
// GOOD: Sync at startup
const config = JSON.parse(fs.readFileSync('config.json', 'utf8'));
const server = http.createServer(handler);
server.listen(3000);

// BAD: Sync in a request handler
app.get('/report', (req, res) => {
    const data = fs.readFileSync('report.csv', 'utf8'); // Blocks ALL requests!
    res.send(data);
});
Sneaky sync operations people miss:
  • require() is synchronous (file read + compile). Never call require() inside a hot path.
  • JSON.parse() on large strings (100MB+ JSON) is CPU-bound and blocks the loop.
  • crypto.pbkdf2Sync — use the async version crypto.pbkdf2 instead.
  • zlib.gzipSync — use streaming zlib.createGzip() instead.
What interviewers are really testing: Do you know which operations are secretly synchronous? Can you reason about the cascading impact on concurrency?Red flag answer: “I never use sync functions” (too absolute — sync at startup is fine and simpler) or “I use sync everywhere because my app isn’t high traffic” (will break the moment traffic increases).Follow-up:
  • “Is require() synchronous or asynchronous? What are the implications of calling require inside a request handler?”
  • “If JSON.parse is synchronous, how would you handle parsing a 500MB JSON file without blocking the event loop?”
  • “How can you detect synchronous operations that are blocking your event loop in production?”
Answer: The zlib module provides streaming compression/decompression using Gzip, Deflate, and Brotli algorithms. It is a Transform stream, making it composable with pipes.Streaming compression (the right way):
const { createGzip, createBrotliCompress } = require('zlib');
const { pipeline } = require('stream/promises');
const fs = require('fs');

// Gzip compression with streaming
await pipeline(
    fs.createReadStream('access.log'),       // 2GB file
    createGzip({ level: 6 }),                // Compression level 1-9
    fs.createWriteStream('access.log.gz')    // ~200MB output
);
// Peak memory: ~64KB (chunk size), NOT 2GB
HTTP compression (Express):
const compression = require('compression');

app.use(compression({
    level: 6,                    // Balance speed vs ratio
    threshold: 1024,             // Don't compress responses under 1KB
    filter: (req, res) => {
        // Skip already-compressed content (images, videos)
        if (req.headers['x-no-compression']) return false;
        return compression.filter(req, res);
    }
}));
Algorithm comparison:
  • Gzip: Universal support, decent ratio. Standard for HTTP (Accept-Encoding: gzip).
  • Brotli: ~15-20% better compression than Gzip, but slower to compress. Most modern browsers support it (Accept-Encoding: br). Use for static assets pre-compressed at build time.
  • Deflate: Legacy. Avoid — inconsistent implementations across clients.
What interviewers are really testing: Do you think about compression as a streaming operation? Do you know when to use Gzip vs Brotli?Red flag answer: “I just add the compression middleware and it handles everything.” Missing nuance about when NOT to compress (small responses, already-compressed content like images).Follow-up:
  • “When would you pre-compress assets at build time vs compress on-the-fly? What’s the trade-off?”
  • “What happens if you Gzip an already-compressed PNG? Does the file get smaller?”
  • “How does Brotli compare to Gzip in terms of compression ratio, CPU cost, and browser support?”
Answer: Read-Eval-Print-Loop — Node’s interactive shell for rapid prototyping and debugging.
$ node
> 2 + 2
4
> const http = require('http')
undefined
> .help    # Show REPL commands
> .editor  # Enter multi-line mode
> .exit    # Quit
Beyond basics — the REPL is a debugging tool:
  • Inspect live objects: Launch your app with --inspect, connect Chrome DevTools, use the console as a REPL against the running process.
  • Custom REPL: You can create a REPL that exposes your app’s internals (database connections, caches) for live debugging in development:
const repl = require('repl');
const r = repl.start('app> ');
r.context.db = require('./db');
r.context.cache = require('./cache');
// Now you can type: app> await db.users.findOne({ id: 1 })
What interviewers are really testing: This is usually a throwaway question. The real test is whether you mention the custom REPL pattern for debugging.Follow-up:
  • “How would you expose a REPL in a running production service for emergency debugging? What are the security implications?”
Answer: The os module provides operating system-related utility methods. Critical for Cluster setup, monitoring, and capacity planning.
const os = require('os');

os.cpus();                // Array of CPU core info (model, speed, times)
os.cpus().length;         // Number of logical cores (for Cluster workers)
os.freemem();             // Free memory in bytes
os.totalmem();            // Total memory in bytes
os.loadavg();             // 1, 5, 15 min load averages (Unix only)
os.networkInterfaces();   // Network interfaces with IPs
os.hostname();            // Machine hostname
os.platform();            // 'linux', 'darwin', 'win32'
os.type();                // 'Linux', 'Darwin', 'Windows_NT'
os.uptime();              // System uptime in seconds
Production uses:
  • Cluster worker count: const workers = os.cpus().length (common pattern, but in containers os.cpus().length may report the host’s CPUs, not the container’s limit — use process.env.NUM_WORKERS or read from cgroup).
  • Health checks: Report freemem() / totalmem() ratio, loadavg().
  • Platform-specific behavior: Conditional logic based on os.platform() for path separators, shell commands, etc.
Container gotcha: In Docker/Kubernetes, os.cpus().length returns the host machine’s CPU count, not the container’s CPU limit. If the host has 64 cores but your pod has a 2-core limit, spawning 64 cluster workers wastes memory and causes excessive context switching.What interviewers are really testing: Do you know the container gotcha? This separates developers from operators.Red flag answer: Using os.cpus().length blindly for cluster workers in a containerized environment.Follow-up:
  • “In a Kubernetes pod with a 2-core CPU limit, os.cpus().length returns 64. How do you determine the correct number of worker processes?”
  • “How would you build a simple system health dashboard using only Node.js built-in modules?“

3. Web & Network (HTTP/Express)

Answer: Node’s http module provides a low-level HTTP server. Understanding it deeply is essential before using Express.
const http = require('http');

const server = http.createServer((req, res) => {
    // req is an http.IncomingMessage (Readable Stream)
    // res is an http.ServerResponse (Writable Stream)
    
    // Reading the request body (it's a stream!)
    let body = '';
    req.on('data', chunk => body += chunk);
    req.on('end', () => {
        res.writeHead(200, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ received: body }));
    });
});

server.listen(3000);
Key insight: req and res are streams. The request body is not immediately available — you must consume the Readable stream. This is why Express needs express.json() middleware (it does this consumption for you).What happens under the hood:
  1. Libuv receives a TCP connection.
  2. http_parser (C library, now llhttp since Node 12) parses the raw bytes into HTTP headers.
  3. Node creates IncomingMessage and ServerResponse objects.
  4. Your callback fires with these objects.
  5. Response is written back through the socket.
Connection handling: The server emits events — 'request' (most common), 'connection' (raw TCP socket), 'upgrade' (WebSocket handshake), 'close'.What interviewers are really testing: Do you understand that req/res are streams? Most developers jump to Express without understanding the raw HTTP server.Red flag answer: “I’ve never used http.createServer, I just use Express.” Express is built on top of this — understanding the foundation matters.Follow-up:
  • “What is the 'upgrade' event on an HTTP server and when would you use it?”
  • “How does http_parser (now llhttp) work, and why was it rewritten?”
  • “What’s the difference between res.end() and res.write() followed by res.end()?”
Answer: Middleware is Express’s core abstraction — a function with access to (req, res, next) that forms a chain of responsibility pattern.Types of middleware:
  1. Application-level: app.use(fn) — runs on every request.
  2. Route-level: router.use(fn) or app.get('/path', fn, handler) — scoped to routes.
  3. Error-handling: (err, req, res, next) — 4-parameter signature, Express detects the arity.
  4. Built-in: express.json(), express.static(), express.urlencoded().
  5. Third-party: cors, helmet, morgan, compression.
Execution flow:
Request --> [cors] --> [helmet] --> [json parser] --> [auth] --> [route handler] --> Response
                                                        |
                                                   [error handler] (if error thrown)
The next() contract:
  • next() — pass to next middleware.
  • next('route') — skip remaining middleware in current route, go to next route.
  • next(err) — skip to error-handling middleware.
  • Not calling next() and not sending a response = request hangs until client timeout.
Real-world middleware stack (ordered):
app.use(helmet());                    // Security headers (first!)
app.use(cors(corsOptions));           // CORS (before routes)
app.use(compression());              // Response compression
app.use(express.json({ limit: '10mb' })); // Body parser with size limit
app.use(morgan('combined'));          // Request logging
app.use(authMiddleware);              // Authentication
app.use('/api', rateLimiter);         // Rate limiting on API routes
app.use('/api', apiRouter);           // Routes
app.use(notFoundHandler);             // 404 handler
app.use(errorHandler);                // Error handler (MUST be last)
What interviewers are really testing: Do you understand middleware ordering matters? Can you reason about what happens when next() is not called?Red flag answer: “Middleware is just a function that runs before the route handler.” This misses the chain-of-responsibility pattern, error middleware, and ordering significance.Follow-up:
  • “What happens if a middleware calls next() AND sends a response? What error do you get?”
  • “How does Express distinguish error-handling middleware from regular middleware?”
  • “You have 15 middleware functions and a request takes 500ms. How do you find which middleware is the bottleneck?”
Answer: Express error-handling middleware has a 4-parameter signature: (err, req, res, next). Express uses the function’s .length property (arity) to detect it.
// This is recognized as error middleware because it has 4 params
app.use((err, req, res, next) => {
    // Log with context
    logger.error({
        err,
        url: req.originalUrl,
        method: req.method,
        userId: req.user?.id,
        requestId: req.headers['x-request-id']
    });
    
    // Don't leak internal errors to clients
    const statusCode = err.statusCode || 500;
    const message = statusCode === 500 ? 'Internal Server Error' : err.message;
    
    res.status(statusCode).json({
        error: message,
        ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
    });
});
Critical placement rule: Error middleware MUST be defined after all routes and other middleware. Express processes middleware in order; if the error handler is defined before a route, errors from that route won’t reach it.The async error problem: Express 4 does NOT catch errors thrown in async route handlers. You need a wrapper:
// Without wrapper: unhandled rejection, server hangs
app.get('/data', async (req, res) => {
    const data = await db.query('SELECT ...'); // If this throws, Express never catches it
    res.json(data);
});

// With wrapper: error is passed to Express error middleware
const asyncHandler = (fn) => (req, res, next) =>
    Promise.resolve(fn(req, res, next)).catch(next);

app.get('/data', asyncHandler(async (req, res) => {
    const data = await db.query('SELECT ...');
    res.json(data);
}));

// Express 5 fixes this natively -- async errors auto-forward to error middleware
What interviewers are really testing: Do you know about the async error gap in Express 4? This is one of the most common production bugs in Node web servers.Red flag answer: “I just add a try/catch in every route handler.” While this works, it’s verbose and error-prone — missing one handler means an unhandled rejection.Follow-up:
  • “Why does Express check the function’s arity to detect error middleware? What happens if you use arrow functions with destructuring that changes the apparent parameter count?”
  • “How does Express 5 handle async errors differently from Express 4?”
  • “How would you implement different error responses for API routes (JSON) vs web routes (HTML error page)?”
Answer: HTTP request bodies are streams — the data arrives in chunks. Body parsing middleware consumes the stream, buffers it, parses it, and attaches the result to req.body.Native approach (no Express):
const server = http.createServer((req, res) => {
    const chunks = [];
    req.on('data', chunk => chunks.push(chunk));
    req.on('end', () => {
        const body = Buffer.concat(chunks).toString();
        const parsed = JSON.parse(body); // Manual parsing
        // Handle request...
    });
});
Express built-in parsers:
// JSON bodies (Content-Type: application/json)
app.use(express.json({ limit: '10mb' }));     // ALWAYS set a limit!

// URL-encoded bodies (Content-Type: application/x-www-form-urlencoded)
app.use(express.urlencoded({ extended: true })); // extended: true uses qs library (nested objects)

// Raw binary (Content-Type: application/octet-stream)
app.use(express.raw({ limit: '50mb' }));

// Plain text (Content-Type: text/plain)
app.use(express.text());
Security considerations:
  • Always set limit: Without a limit, an attacker can send a 1GB JSON body and OOM your server. Default is '100kb'.
  • extended: true vs false: extended: true uses the qs library which supports nested objects (user[name]=foo). extended: false uses querystring which only supports flat key-value pairs. Nested object parsing can be exploited for prototype pollution — validate inputs.
  • Content-Type spoofing: A request claiming Content-Type: application/json but sending garbage will cause JSON.parse to throw. The Express json() middleware handles this and returns 400.
What interviewers are really testing: Do you set body size limits? Do you understand that body parsing is a streaming operation?Red flag answer: Using express.json() without a limit option, or not knowing that req.body requires middleware to be populated.Follow-up:
  • “What happens if express.json() is not in the middleware stack and you try to access req.body?”
  • “How would you handle a request that sends Content-Type: application/json but the body is invalid JSON?”
  • “Why is setting a body size limit a security measure? What specific attack does it prevent?”
Answer: Cross-Origin Resource Sharing — the browser’s mechanism for controlling which origins can make requests to your API.How CORS actually works:
  1. Simple requests (GET, POST with form content types): Browser adds Origin header, server responds with Access-Control-Allow-Origin. If it doesn’t match, browser blocks the response (the request still hits your server!).
  2. Preflight requests (PUT, DELETE, custom headers, JSON content type): Browser sends an OPTIONS request first with Access-Control-Request-Method and Access-Control-Request-Headers. Server must respond with allowed methods/headers. Only then does the actual request fire.
Key headers:
  • Access-Control-Allow-Origin: Which origins can access (* or specific origin)
  • Access-Control-Allow-Methods: Allowed HTTP methods
  • Access-Control-Allow-Headers: Allowed custom headers
  • Access-Control-Allow-Credentials: Whether cookies/auth are allowed (true/omit)
  • Access-Control-Max-Age: How long to cache preflight results (seconds)
Critical gotcha: Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true are mutually exclusive. If you need cookies/auth, you must specify the exact origin.
const cors = require('cors');

// Production config (NOT `origin: '*'` with credentials)
app.use(cors({
    origin: ['https://app.example.com', 'https://admin.example.com'],
    credentials: true,
    methods: ['GET', 'POST', 'PUT', 'DELETE'],
    allowedHeaders: ['Content-Type', 'Authorization'],
    maxAge: 86400   // Cache preflight for 24 hours
}));
CORS is NOT a server-side security measure: CORS is enforced by browsers. curl, Postman, and server-to-server requests completely bypass CORS. It protects users from malicious websites making requests on their behalf, not your API from all unauthorized access.What interviewers are really testing: Do you understand preflight requests? Do you know CORS is browser-enforced, not server-enforced?Red flag answer: “I just set Access-Control-Allow-Origin: * to fix CORS errors.” This disables the protection entirely and breaks credentialed requests.Follow-up:
  • “A frontend developer says ‘I’m getting a CORS error.’ Where is the problem — frontend or backend? How do you debug it?”
  • “Why can’t you use origin: '*' with credentials: true? What would happen if you could?”
  • “If CORS is browser-only, how do you protect your API from unauthorized server-to-server access?”
Answer: JWT is a stateless authentication token — the server doesn’t need to store session data. The token itself contains all the information needed to authenticate the user.Structure (three Base64URL-encoded parts separated by dots):
  1. Header: Algorithm and token type — { "alg": "HS256", "typ": "JWT" }
  2. Payload: Claims (data) — { "sub": "user123", "role": "admin", "exp": 1700000000 }
  3. Signature: HMACSHA256(base64(header) + "." + base64(payload), secret) — proves the token hasn’t been tampered with.
Critical security details:
  • The payload is NOT encrypted — it’s Base64 encoded (anyone can decode it). Never put passwords, SSNs, or sensitive data in the payload.
  • Storage options (ranked by security):
    1. HttpOnly + Secure + SameSite=Strict cookie (best — immune to XSS)
    2. In-memory variable (lost on page refresh, but very secure)
    3. LocalStorage (convenient but vulnerable to XSS attacks)
  • HS256 vs RS256: HS256 uses a shared secret (both sides know it). RS256 uses a public/private key pair (only the auth server has the private key — better for microservices where you don’t want to distribute secrets).
Token refresh pattern:
  • Short-lived access token (15 min) + long-lived refresh token (7 days).
  • Refresh token stored in HttpOnly cookie, access token in memory.
  • When access token expires, use refresh token to get a new one.
What interviewers are really testing: Do you know JWTs are not encrypted? Do you understand the storage trade-offs? Can you explain why refresh tokens exist?Red flag answer: “I store the JWT in localStorage” without acknowledging the XSS risk, or “JWTs are encrypted so they’re secure.”Follow-up:
  • “How do you revoke a JWT before it expires? Doesn’t that defeat the purpose of stateless auth?”
  • “What’s the difference between HS256 and RS256? When would you choose one over the other?”
  • “A JWT is stolen. What damage can the attacker do, and how do you limit the blast radius?”
Answer: This is about where state lives — client-side vs server-side.Sessions (server-side state):
  • A random session ID is stored in a cookie on the client.
  • The actual session data (user info, cart, preferences) lives on the server in a session store (memory, Redis, database).
  • Stateful: The server must look up the session on every request.
  • Pros: Data isn’t exposed to the client, easy to invalidate (delete server-side), no size limit.
  • Cons: Server must maintain state (scaling challenge), requires sticky sessions or shared store in clusters.
Cookies (client-side state):
  • Data is stored directly in the browser cookie.
  • Sent automatically with every request to the cookie’s domain.
  • Size limit: ~4KB per cookie.
  • Security flags: HttpOnly (no JS access), Secure (HTTPS only), SameSite (CSRF protection), Domain, Path.
The scaling question: Sessions in server memory break when you scale horizontally (load balancer sends request to different server). Solutions:
  1. Redis session store: All servers read/write sessions to shared Redis. Standard approach.
  2. Sticky sessions: Load balancer always routes a user to the same server. Fragile — if that server dies, all its sessions are lost.
  3. JWT (stateless): No server-side state needed. Trade-off: harder to revoke.
What interviewers are really testing: Can you reason about state management in distributed systems? Do you know why “sessions in memory” breaks at scale?Red flag answer: “Cookies store data, sessions store data, they’re basically the same.” Misses the fundamental architectural distinction.Follow-up:
  • “You have 10 Node servers behind a load balancer. How do you handle sessions without sticky sessions?”
  • “What’s the SameSite cookie attribute and how does it protect against CSRF?”
  • “When would you choose session-based auth over JWT, and vice versa?”
Answer: File uploads use multipart/form-data encoding — the body contains multiple parts separated by a boundary string. You need specialized parsing.Common approaches:
  1. Multer (Express middleware): The standard choice. Handles multipart/form-data parsing, stores files to disk or memory.
  2. Busboy/Busbody (low-level stream parser): Direct stream access, more control, works with any Node HTTP server.
  3. Formidable: Alternative to Multer with streaming support.
Production-grade upload pattern (stream to S3, never touch disk):
const multer = require('multer');
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const { Upload } = require('@aws-sdk/lib-storage');

// Use memory storage for small files, stream for large
const upload = multer({
    storage: multer.memoryStorage(),
    limits: {
        fileSize: 10 * 1024 * 1024,  // 10MB limit
        files: 5                       // Max 5 files
    },
    fileFilter: (req, file, cb) => {
        const allowed = ['image/jpeg', 'image/png', 'application/pdf'];
        cb(null, allowed.includes(file.mimetype));
    }
});
Security checklist:
  • Validate MIME type on the server (don’t trust Content-Type — read magic bytes).
  • Set file size limits — unbounded uploads = DoS vector.
  • Generate random filenames — never use the original filename (path traversal attacks: ../../etc/passwd).
  • Don’t serve uploads from the same domain — use a separate CDN/bucket to prevent XSS via uploaded HTML/SVG files.
  • Virus scan uploaded files in production (ClamAV or cloud-based scanning).
Serverless/container consideration: Don’t write files to local disk in ephemeral environments (Lambda, ECS Fargate). Stream directly to object storage (S3, GCS).What interviewers are really testing: Do you think about security, size limits, and storage strategy? Or do you just save to disk and serve from the same server?Red flag answer: “I save the file to the uploads/ directory and serve it from Express.” This has security, scaling, and reliability problems.Follow-up:
  • “How would you handle a 5GB video upload without running out of memory?”
  • “Why should you never serve user-uploaded files from the same origin as your application?”
  • “How do you validate that an uploaded file is actually a JPEG and not a renamed executable?”
Answer: WebSockets provide persistent, full-duplex communication over a single TCP connection. Unlike HTTP’s request-response model, either side can send data at any time.The handshake:
  1. Client sends an HTTP request with Upgrade: websocket header.
  2. Server responds with 101 Switching Protocols.
  3. The TCP connection is now a WebSocket — no more HTTP framing.
Raw WebSocket (ws library) vs Socket.IO:
  • ws: Thin wrapper over the WebSocket protocol. Lightweight, no fallbacks, no rooms, no auto-reconnection. ~40KB.
  • Socket.IO: Full-featured library with auto-reconnection, rooms/namespaces, binary support, fallback to HTTP long-polling, acknowledgements. ~300KB client bundle. Uses its own protocol on top of WebSocket.
  • Key decision: If all your clients support WebSocket (modern browsers, mobile apps), use ws. If you need reliability features and broadcasting, use Socket.IO.
Scaling WebSockets (the hard part):
  • WebSocket connections are stateful — each is tied to a specific server process.
  • With multiple servers, you need a pub/sub adapter (e.g., @socket.io/redis-adapter) so a message emitted on Server A reaches clients connected to Server B.
  • Load balancers must support WebSocket upgrades (sticky sessions or connection upgrade pass-through).
  • 10k connections per server process is reasonable; 100k+ requires tuning OS limits (ulimit -n).
What interviewers are really testing: Do you know the difference between ws and Socket.IO? Can you reason about scaling stateful connections?Red flag answer: “Socket.IO is WebSocket.” No — Socket.IO is a library that uses WebSocket as one of its transports (with HTTP long-polling as fallback).Follow-up:
  • “You have a chat app on 10 servers. User A is connected to Server 3, User B to Server 7. How does a message get from A to B?”
  • “What happens to WebSocket connections during a deployment/restart? How do you handle this gracefully?”
  • “When would you choose Server-Sent Events (SSE) over WebSocket?”
Answer: By default in older Node.js versions, every outgoing HTTP request creates a new TCP connection — that’s a 3-way TCP handshake + potential TLS handshake on every request. This is extremely wasteful.The solution — HTTP Agent with Keep-Alive:
const http = require('http');

const agent = new http.Agent({
    keepAlive: true,         // Reuse connections
    maxSockets: 50,          // Max concurrent connections per host
    maxFreeSockets: 10,      // Max idle connections to keep
    timeout: 60000           // Socket timeout
});

// Use with requests
http.get('http://api.example.com/data', { agent }, (res) => { /* ... */ });
Performance impact: For a service making 1000 req/s to a downstream API:
  • Without keep-alive: 1000 TCP handshakes/sec (~1ms each) = 1 second of cumulative handshake overhead, plus ephemeral port exhaustion risk.
  • With keep-alive: ~50 persistent connections handle all 1000 requests. Near-zero handshake overhead.
Node 19+ change: globalAgent has keepAlive: true by default. Before Node 19, you had to explicitly enable it.Common pitfall — socket exhaustion: If maxSockets is too low and request volume is high, requests queue behind the socket limit. If it’s too high, you might exhaust the downstream server’s connection limit.
// For axios users
const axios = require('axios');
const https = require('https');

const client = axios.create({
    httpsAgent: new https.Agent({
        keepAlive: true,
        maxSockets: 100
    })
});
What interviewers are really testing: Do you know about connection reuse and its impact on latency/throughput? Have you tuned socket pool settings?Red flag answer: “HTTP connections are handled automatically, you don’t need to configure anything.” True in modern Node, but this shows no understanding of what’s happening underneath.Follow-up:
  • “What is ephemeral port exhaustion and how does keep-alive prevent it?”
  • “You see ECONNRESET errors from a downstream service. What’s happening and how does the HTTP agent configuration relate?”
  • “How do you monitor the state of your connection pool (active, idle, pending connections)?“

4. Scaling & Performance

Answer: The Cluster module lets you create child processes (workers) that share the same server port, utilizing multiple CPU cores.How it works:
  1. Master process calls cluster.fork() to create worker processes.
  2. Workers are full Node.js instances with their own V8 and event loop.
  3. The master accepts incoming connections and distributes them to workers.
  4. Load balancing: Round-robin (default on all platforms except Windows) or OS-level (let the kernel decide).
const cluster = require('cluster');
const os = require('os');

if (cluster.isPrimary) {  // 'isPrimary' replaces deprecated 'isMaster' in Node 16+
    const numWorkers = os.cpus().length;
    console.log(`Master ${process.pid} starting ${numWorkers} workers`);
    
    for (let i = 0; i < numWorkers; i++) {
        cluster.fork();
    }
    
    cluster.on('exit', (worker, code, signal) => {
        console.log(`Worker ${worker.process.pid} died (${signal || code}). Restarting...`);
        cluster.fork(); // Auto-restart
    });
} else {
    require('./server'); // Each worker runs the HTTP server
}
What workers share: The listening port (via file descriptor passing). What workers do NOT share: Memory. Each worker has its own heap, its own V8 instance. Global variables in one worker are invisible to others. In-memory caches are duplicated per worker.Implications for state:
  • Session data in memory? Each worker has its own copy — requests to different workers get different sessions. Use Redis.
  • In-memory cache? Duplicated N times (N = workers). Use Redis.
  • Rate limiting in memory? Each worker tracks independently — actual rate is N times the limit. Use Redis.
Worker count heuristic: os.cpus().length for CPU-bound workloads, os.cpus().length * 1.5 - 2 for I/O-bound workloads (workers spend time waiting on I/O so more can be useful).What interviewers are really testing: Do you understand the memory isolation between workers? Can you reason about what breaks when you naively cluster a stateful app?Red flag answer: “Cluster makes your app use all cores and everything just works.” Missing the stateful implications.Follow-up:
  • “What happens to in-progress requests when a worker crashes? Are they lost?”
  • “How does the master process distribute connections to workers? What’s the difference between round-robin and OS scheduling?”
  • “Why might PM2’s cluster mode be preferred over using the cluster module directly?”
Answer: PM2 is a production process manager for Node.js that handles concerns you’d otherwise implement manually.Key features:
  • Cluster mode: pm2 start app.js -i max — forks workers for all CPU cores.
  • Auto-restart on crash: Worker dies, PM2 restarts it within milliseconds. Configurable restart limits.
  • Zero-downtime reload: pm2 reload app — restarts workers one at a time (new worker starts before old one stops). No dropped requests.
  • Log management: Aggregates stdout/stderr from all workers. pm2 logs, log rotation.
  • Monitoring: pm2 monit shows real-time CPU, memory, event loop lag per process.
  • Ecosystem file: ecosystem.config.js for declarative configuration.
// ecosystem.config.js
module.exports = {
    apps: [{
        name: 'api',
        script: './src/server.js',
        instances: 'max',           // All cores
        exec_mode: 'cluster',
        max_memory_restart: '500M', // Auto-restart if memory exceeds 500MB
        env: {
            NODE_ENV: 'production',
            PORT: 3000
        },
        // Graceful shutdown
        kill_timeout: 5000,         // Wait 5s for connections to drain
        listen_timeout: 10000       // Wait 10s for ready signal
    }]
};
PM2 vs Docker/Kubernetes: In containerized environments, you typically run one process per container and let the orchestrator handle scaling, restarts, and health checks. PM2’s cluster mode inside a container is usually redundant — instead, scale by increasing container replicas. However, PM2 is still useful for non-containerized deployments (bare EC2, traditional VMs).What interviewers are really testing: Do you know when PM2 is appropriate vs when the orchestrator handles it? Can you explain zero-downtime reload?Red flag answer: “I use PM2 in production inside my Docker container in cluster mode.” This is usually double-clustering and complicates debugging.Follow-up:
  • “How does PM2’s zero-downtime reload work? What happens to in-flight requests during the reload?”
  • “In a Kubernetes deployment, would you still use PM2? Why or why not?”
  • “How would you configure PM2 to auto-restart a worker that exceeds 500MB of memory?”
Answer: Worker Threads (worker_threads module) enable true parallel JavaScript execution within a single Node.js process. Unlike Cluster (separate processes), workers share the same process and can share memory.Key differences from Cluster:
AspectClusterWorker Threads
IsolationSeparate processes, separate memorySame process, can share memory
Overhead~30MB per worker (full V8 instance)~5-10MB per worker (V8 isolate)
CommunicationIPC (serialized messages)postMessage (structured clone) or SharedArrayBuffer (zero-copy)
Use caseScaling HTTP serversCPU-intensive tasks
Crash impactWorker crash doesn’t affect othersWorker crash can affect the process
SharedArrayBuffer for zero-copy communication:
// Main thread
const { Worker } = require('worker_threads');

const sharedBuffer = new SharedArrayBuffer(4);
const arr = new Int32Array(sharedBuffer);
arr[0] = 42;

const worker = new Worker('./worker.js', { workerData: { sharedBuffer } });
// Worker can read/write arr[0] directly -- no serialization needed!
Gotcha — atomics and race conditions: If two threads write to the same SharedArrayBuffer location, you get race conditions. Use Atomics.add(), Atomics.compareExchange(), etc. for thread-safe operations.When to use Worker Threads:
  • Image resizing (sharp library already uses threads internally)
  • Parsing large JSON/CSV files
  • Cryptographic operations (bcrypt, key derivation)
  • Data compression
  • Number crunching, simulations
When NOT to use Worker Threads:
  • I/O-bound work (that’s what the event loop is for)
  • Simple request handling (overhead of creating/managing threads outweighs benefit)
What interviewers are really testing: Can you distinguish between Worker Threads and Cluster? Do you know about SharedArrayBuffer and the race condition risks?Red flag answer: “Worker Threads and Cluster are the same thing.” They have fundamentally different memory models and use cases.Follow-up:
  • “How does postMessage serialize data between threads? What types can’t be transferred?”
  • “What is a Transferable object and when would you use worker.postMessage(data, [transferList])?”
  • “You need to process 10,000 images. Do you create 10,000 worker threads? What’s your architecture?”
Answer: Memory leaks in Node.js are insidious — the process slowly consumes more memory over hours/days until it OOMs. Here are the most common causes ranked by how frequently they appear in production.Top causes:
  1. Event Listener Accumulation (most common):
// BUG: Adding a listener on EVERY request -- never removing it
app.get('/stream', (req, res) => {
    emitter.on('data', (d) => res.write(d));
    // After response ends, the listener is still attached!
    // After 10k requests: 10k listeners, 10k closures holding `res` objects
});
Fix: Remove listeners when done, or use .once().
  1. Closures Holding Large Objects:
function processData() {
    const hugeArray = new Array(1_000_000).fill('x'); // 10MB
    return function getFirstItem() {
        return hugeArray[0]; // Closure keeps hugeArray alive!
    };
}
// The returned function prevents hugeArray from being GC'd
  1. Global Variables / Module-Level Caches Without TTL:
// Module-level cache grows unboundedly
const cache = {};
function getData(key) {
    if (!cache[key]) {
        cache[key] = fetchFromDB(key); // Never evicted!
    }
    return cache[key];
}
Fix: Use LRU cache with max size (lru-cache package) or Redis with TTL.
  1. Unreferenced Timers:
// setInterval without clearInterval
const intervalId = setInterval(fetchMetrics, 1000);
// If this module is "hot-reloaded", the old interval keeps running
  1. Forgotten Streams: Readable streams that are created but never consumed or destroyed hold their internal buffer in memory.
Detection tools:
  • process.memoryUsage() logged over time (look for monotonic heapUsed growth)
  • --inspect + Chrome DevTools heap snapshots (compare two snapshots, look at “Objects allocated between snapshots”)
  • clinic doctor / clinic heapprofiler for automated analysis
  • node --heapsnapshot-signal=SIGUSR2 — take a heap snapshot on demand
What interviewers are really testing: Can you name specific patterns (not just “memory leaks happen”)? Do you know how to detect and diagnose them?Red flag answer: “Memory leaks don’t really happen in JavaScript because of garbage collection.” GC only collects unreachable objects — all the patterns above keep objects reachable.Follow-up:
  • “Walk me through how you’d diagnose a memory leak in a production Node service that’s slowly growing from 200MB to 2GB over 24 hours.”
  • “What’s the difference between a memory leak and high memory usage? How do you distinguish them?”
  • “How does the WeakRef / WeakMap help prevent certain kinds of memory leaks?”
Answer: The N+1 problem is a query explosion pattern where fetching a list of N items triggers N additional queries for related data.Example:
// Query 1: Fetch 50 users
const users = await User.findAll();

// N queries: For each user, fetch their posts
for (const user of users) {
    user.posts = await Post.findAll({ where: { userId: user.id } });
    // This executes 50 separate SQL queries!
}
// Total: 1 + 50 = 51 queries. Should be 2.
Solutions:
  1. Eager Loading (ORM-level): Load related data in a JOIN or second query.
// Sequelize: 2 queries instead of 51
const users = await User.findAll({ include: [Post] });

// Prisma
const users = await prisma.user.findMany({ include: { posts: true } });
  1. DataLoader (GraphQL): Batches individual lookups within a single event loop tick into one query.
const DataLoader = require('dataloader');

const postLoader = new DataLoader(async (userIds) => {
    // ONE query: SELECT * FROM posts WHERE userId IN (1,2,3,...50)
    const posts = await Post.findAll({ where: { userId: userIds } });
    // Return in the same order as input userIds
    return userIds.map(id => posts.filter(p => p.userId === id));
});

// In resolver -- each call is batched automatically
const resolvers = {
    User: {
        posts: (user) => postLoader.load(user.id) // Batched!
    }
};
  1. Database Views / Denormalization: For read-heavy paths, pre-compute the joined data.
Why this matters at scale: 51 queries at 2ms each = 102ms. Seems fine. But with 100 concurrent requests = 5,100 queries hitting your database simultaneously. With proper batching: 200 queries. That’s a 25x reduction in database load.What interviewers are really testing: Do you know DataLoader specifically? Can you reason about database load multiplication?Red flag answer: “I just add .include() to everything.” Eager loading everything causes over-fetching and can be worse than N+1 for deeply nested relationships.Follow-up:
  • “How does DataLoader batch requests? What’s the mechanism that collects individual .load() calls into a single batch?”
  • “DataLoader caches results per-request. What happens if you share a DataLoader across requests?”
  • “When is eager loading worse than N+1? Give a scenario.”
Answer: Every database query needs a TCP connection. Opening a new TCP connection involves a 3-way handshake (~1ms on LAN, 50-100ms cross-region), plus TLS negotiation (~5-30ms), plus database authentication. Connection pooling amortizes this cost.How pools work:
  1. At startup, the pool opens min connections.
  2. When your code needs a connection, it borrows one from the pool.
  3. After the query completes, the connection is returned to the pool (not closed).
  4. If all connections are in use, new requests wait in a queue until one is returned.
  5. If the queue exceeds a timeout, the request fails with a connection timeout error.
// PostgreSQL pool configuration
const { Pool } = require('pg');
const pool = new Pool({
    host: process.env.DB_HOST,
    max: 20,                        // Max connections in pool
    min: 5,                         // Min idle connections
    idleTimeoutMillis: 30000,       // Close idle connections after 30s
    connectionTimeoutMillis: 5000,  // Fail if can't get connection in 5s
    maxUses: 7500                   // Close connection after 7500 queries (prevent stale)
});
Sizing the pool (critical production decision):
  • Too small: Requests queue up waiting for connections. Latency spikes under load.
  • Too large: Each connection uses ~5-10MB on the database server. 100 connections x 10 workers = 1000 connections on the DB, overwhelming it.
  • Rule of thumb: Start with max = (2 * CPU cores) + effective_spindle_count (per PostgreSQL docs), typically 10-20 per Node process.
  • With Cluster mode: If you have 8 workers, each with a pool of 20 = 160 total connections. Make sure your database can handle this.
Connection pooler tools: For large deployments, use a connection pooler like PgBouncer (PostgreSQL) or ProxySQL (MySQL) between your app and the database. They multiplex hundreds of application connections onto a small number of actual database connections.What interviewers are really testing: Can you size a pool correctly? Do you know about the connection multiplication problem with Cluster/multiple instances?Red flag answer: “I set max: 100 to be safe.” More connections is not safer — it can kill your database.Follow-up:
  • “You have 10 Node instances, each with a pool of 20 connections. Your database allows 100 connections max. What happens?”
  • “What is PgBouncer and when would you use it?”
  • “How do you monitor pool health (idle connections, queue depth, wait time)?”
Answer: Caching is the most impactful performance optimization you can make, but choosing the wrong strategy creates consistency bugs that are hard to debug.Two categories:
  1. In-Process (Node memory):
    • Map, lru-cache, node-cache.
    • Pros: Fastest possible (~nanoseconds), no network hop.
    • Cons: Lost on restart, duplicated in every Cluster worker, no sharing between instances, bounded by V8 heap size.
    • Use for: Compiled regex, parsed config, tiny lookup tables that change rarely.
  2. Distributed (Redis/Memcached):
    • Shared across all instances, survives restarts (Redis with AOF/RDB persistence).
    • Pros: Shared, persistent, rich data structures (sorted sets for leaderboards, pub/sub, Lua scripting).
    • Cons: Network hop (~0.5-2ms per request), serialization overhead.
    • Use for: API response caching, session storage, rate limit counters, feature flags.
Cache invalidation strategies (the hard part):
  • TTL (Time-To-Live): Set expiry. Simple but allows stale data during TTL window.
  • Write-Through: Write to cache AND database simultaneously. Consistent but slower writes.
  • Write-Behind (Write-Back): Write to cache, asynchronously flush to database. Fast but data loss risk.
  • Cache-Aside (Lazy Loading): Check cache first, miss goes to DB, result written to cache. Most common pattern.
// Cache-aside pattern with Redis
async function getUser(id) {
    const cacheKey = `user:${id}`;
    
    // 1. Check cache
    const cached = await redis.get(cacheKey);
    if (cached) return JSON.parse(cached);
    
    // 2. Cache miss -- fetch from DB
    const user = await db.users.findById(id);
    if (!user) return null;
    
    // 3. Populate cache with TTL
    await redis.set(cacheKey, JSON.stringify(user), 'EX', 3600);
    return user;
}

// On update, invalidate cache
async function updateUser(id, data) {
    await db.users.update(id, data);
    await redis.del(`user:${id}`); // Invalidate
}
What interviewers are really testing: Do you know multiple caching strategies and their trade-offs? Can you reason about cache invalidation?Red flag answer: “I cache everything in a global object” — no eviction, no TTL, duplicated across workers, grows unbounded.Follow-up:
  • “What is a cache stampede (thundering herd) and how do you prevent it?”
  • “How do you decide what TTL to set? What’s the trade-off between a 10-second TTL and a 1-hour TTL?”
  • “Your cache and database are out of sync. How do you detect and fix this?”
Answer: Profiling is how you find the actual bottleneck instead of guessing. Node has excellent built-in and ecosystem profiling tools.CPU Profiling (finding slow functions):
# Built-in V8 profiler
node --prof app.js                           # Generates isolate-*.log
node --prof-process isolate-*.log > processed.txt  # Human-readable

# Chrome DevTools (interactive)
node --inspect app.js
# Open chrome://inspect, go to "Profiler" tab, record a CPU profile
Flame Graphs (visual CPU profiling):
# clinic.js (the best all-in-one tool)
npx clinic flame -- node app.js
# Generates an interactive flame graph HTML file

# 0x (standalone flame graph)
npx 0x app.js
Reading a flame graph: Width = time spent. Look for wide bars (functions that take the most time). Tall stacks = deep call chains. Plateau patterns = the same function called many times (potential optimization target).Memory Profiling:
# Heap snapshot
node --inspect app.js
# Chrome DevTools > Memory > Take heap snapshot
# Take two snapshots, compare "Objects allocated between Snapshot 1 and Snapshot 2"

# Heap timeline
node --inspect app.js
# Chrome DevTools > Memory > Allocation timeline
# Shows which allocations survive GC (potential leaks)
Event Loop Profiling (finding blockage):
npx clinic doctor -- node app.js
# Automatically detects: event loop lag, I/O issues, GC problems
Production monitoring metrics to track:
  • Event loop lag (P50, P99)
  • Heap used / heap total
  • GC pause duration and frequency
  • Active handles and requests (process._getActiveHandles().length)
What interviewers are really testing: Have you actually profiled a Node app? Can you interpret a flame graph? Do you know the difference between CPU and memory profiling?Red flag answer: “I add console.time() everywhere.” This is manual and imprecise — real profiling tools show the full picture.Follow-up:
  • “You have a Node API where P99 latency jumped from 50ms to 500ms. Walk me through your profiling approach.”
  • “What’s the difference between a CPU profile and a flame graph? When would you use each?”
  • “How do you profile a Node app in production without significant performance overhead?”
Answer: Even without explicit fs.readFileSync calls, several common operations can block the event loop and cause latency spikes.Common blockers people miss:
  1. JSON.parse / JSON.stringify on large objects: These are synchronous and CPU-bound. A 50MB JSON string can block the loop for 200-500ms.
// BAD: Parsing a huge API response on the main thread
const data = JSON.parse(hugeJsonString); // 500ms block!

// BETTER: Use a streaming JSON parser or Worker Thread
const { Worker } = require('worker_threads');
// Offload to worker for objects > 1MB
  1. Regex Denial of Service (ReDoS): Certain regex patterns with backtracking can take exponential time on crafted inputs.
// DANGEROUS: Catastrophic backtracking
const evil = /^(a+)+$/;
evil.test('aaaaaaaaaaaaaaaaaaaaaaaaaaa!'); // Hangs for seconds/minutes!

// SAFE: Avoid nested quantifiers, use linear-time regex engines
  1. Sorting large arrays: Array.sort() on 1M+ elements blocks for 100ms+.
  2. Crypto operations: crypto.pbkdf2Sync, crypto.scryptSync — always use async versions.
  3. Template rendering: Rendering complex templates (EJS, Handlebars) with large datasets.
  4. require() in hot paths: Module loading reads files synchronously and compiles them.
How to detect event loop blocking:
// Simple lag monitor
const CHECK_INTERVAL = 100; // ms
let lastCheck = process.hrtime.bigint();

setInterval(() => {
    const now = process.hrtime.bigint();
    const elapsed = Number(now - lastCheck) / 1_000_000; // Convert to ms
    const lag = elapsed - CHECK_INTERVAL;
    if (lag > 20) {
        console.warn(`Event loop lag: ${lag.toFixed(1)}ms`);
    }
    lastCheck = now;
}, CHECK_INTERVAL).unref();
Libraries: blocked-at (detects which call site is blocking), event-loop-lag (metrics).What interviewers are really testing: Can you identify non-obvious blockers? Do you have a strategy for detecting them?Red flag answer: “I avoid blocking by using async/await everywhere.” async/await only helps with I/O — await JSON.parse(huge) still blocks because JSON.parse is synchronous.Follow-up:
  • “How would you safely parse a 100MB JSON file without blocking the event loop?”
  • “What is ReDoS and how do you protect against it? Can you write a safe regex for email validation?”
  • “You notice periodic 200ms latency spikes every few minutes. How would you determine if the event loop is being blocked and by what?”
Answer: How services communicate is one of the most consequential architectural decisions. Each pattern has specific strengths and weaknesses.Synchronous (request-response):
  • HTTP/REST: Universal, human-readable (JSON), easy to debug with curl. Overhead: TCP connection + TLS + HTTP framing + JSON serialization. Latency: ~5-50ms per hop. Use for: CRUD APIs, simple service-to-service calls.
  • gRPC: Protocol Buffers (binary serialization, 5-10x smaller than JSON), HTTP/2 (multiplexing, streaming), strict contract via .proto files. Latency: ~1-5ms per hop. Use for: high-throughput internal communication, streaming data, polyglot environments.
Asynchronous (event-driven):
  • Message Queues (RabbitMQ): Point-to-point or fan-out. Guaranteed delivery with acknowledgements. Use for: task queues, work distribution, decoupling services that don’t need immediate responses.
  • Event Streaming (Kafka): Distributed log, ordered within partitions, replay capability, high throughput (millions of messages/sec). Use for: event sourcing, real-time analytics pipelines, audit logs, inter-service events at scale.
  • Redis Pub/Sub: Simple, fast, fire-and-forget (no persistence/replay). Use for: real-time notifications, cache invalidation broadcasts.
Choosing between them:
FactorRESTgRPCMessage QueueKafka
LatencyMediumLowVariableLow-Medium
CouplingTightTightLooseLoose
ReliabilityRetry neededRetry neededBuilt-inBuilt-in
DebuggingEasy (curl)HarderHarderHarder
StreamingNo (workarounds)NativeNoNative
Pattern: Saga for distributed transactions: When an order involves inventory, payment, and shipping services, you can’t use a single database transaction. Use the Saga pattern — each service performs its step and publishes an event. If any step fails, compensating transactions undo previous steps.What interviewers are really testing: Can you pick the right communication pattern for a given scenario? Do you understand sync vs async trade-offs?Red flag answer: “REST is always the best because it’s simple.” Simplicity matters, but using REST for everything leads to tight coupling and cascading failures.Follow-up:
  • “Service A calls Service B, which calls Service C. Service C is down. What happens with synchronous REST vs async messaging?”
  • “When would you choose Kafka over RabbitMQ? What’s the fundamental architectural difference?”
  • “How do you handle distributed transactions across microservices that each have their own database?“

5. Security & Testing

Answer: Injection attacks exploit the boundary between code and data — when user input is treated as executable query logic.SQL Injection:
// VULNERABLE: String concatenation
const query = `SELECT * FROM users WHERE email = '${req.body.email}'`;
// Attacker sends: email = "'; DROP TABLE users; --"
// Executed: SELECT * FROM users WHERE email = ''; DROP TABLE users; --'

// SAFE: Parameterized queries (prepared statements)
const result = await pool.query(
    'SELECT * FROM users WHERE email = $1',
    [req.body.email]  // Value is never interpreted as SQL
);
NoSQL Injection (MongoDB):
// VULNERABLE: Passing raw request body to query
const user = await User.findOne({
    email: req.body.email,
    password: req.body.password
});
// Attacker sends: { "email": "admin@site.com", "password": { "$ne": null } }
// MongoDB interprets $ne as an operator: password != null (always true!)

// SAFE: Explicitly cast to string or use a validation library
const user = await User.findOne({
    email: String(req.body.email),
    password: String(req.body.password) // Forces string, $ne is treated as literal
});

// BETTER: Use mongo-sanitize or express-mongo-sanitize
const sanitize = require('express-mongo-sanitize');
app.use(sanitize()); // Strips $ and . from req.body, req.query, req.params
Defense in depth:
  1. Parameterized queries (primary defense — prevents the attack entirely)
  2. Input validation (reject unexpected types/formats before they reach the query)
  3. Least privilege (DB user should have minimal permissions — no DROP TABLE)
  4. ORM/ODM (Sequelize, Prisma, Mongoose — they parameterize by default, but raw queries still need care)
  5. WAF rules (Web Application Firewall as an additional layer)
What interviewers are really testing: Do you know about NoSQL injection (not just SQL)? Do you rely on parameterized queries or just “sanitization”?Red flag answer: “I use an ORM so injection is impossible.” ORMs help, but raw queries within an ORM are still vulnerable. Also, ORM query builder methods can still be exploited if you pass unsanitized objects.Follow-up:
  • “Can injection happen with an ORM like Prisma or Sequelize? How?”
  • “What’s the difference between parameterized queries and escaping/sanitizing input? Which is more reliable?”
  • “How would you test for injection vulnerabilities in an existing codebase?”
Answer: XSS occurs when an attacker injects malicious JavaScript that executes in another user’s browser. It’s the most common web vulnerability.Three types:
  1. Stored XSS: Attacker saves malicious script in the database (e.g., a comment containing a script tag that steals cookies via document.cookie). Every user who views the comment executes the script.
  2. Reflected XSS: Malicious script is in the URL and reflected in the response: https://site.com/search?q=<script>alert(1)</script>.
  3. DOM-based XSS: Client-side JS inserts untrusted data into the DOM without sanitization: document.innerHTML = userInput.
Backend defenses (Node.js perspective):
  1. Output encoding/escaping: Use a templating engine that auto-escapes (EJS with <%= %>, Handlebars, React’s JSX). Never use <%- %> (raw output) with user data.
  2. Content Security Policy (CSP) header: Restricts which scripts can execute.
// Helmet.js sets CSP
app.use(helmet.contentSecurityPolicy({
    directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", "'nonce-abc123'"], // Only scripts with this nonce run
        styleSrc: ["'self'", "'unsafe-inline'"],
        imgSrc: ["'self'", "data:", "https:"],
    }
}));
  1. HttpOnly cookies: Prevents JavaScript from accessing cookies (document.cookie returns nothing).
  2. Input sanitization (secondary defense): Libraries like DOMPurify (client-side) or sanitize-html (server-side) for when you must accept HTML input (rich text editors).
What interviewers are really testing: Do you know all three types? Do you rely on CSP, not just escaping? Can you articulate why HttpOnly cookies matter?Red flag answer: “I just escape all user input.” Escaping prevents stored/reflected XSS but doesn’t protect against DOM-based XSS. Also, CSP is the strongest defense layer.Follow-up:
  • “What is Content Security Policy and how does it prevent XSS even if your escaping has a bug?”
  • “How does React prevent XSS by default? Can you still have XSS in a React app?”
  • “What’s the difference between encoding/escaping and sanitizing? When do you use each?”
Answer: CSRF tricks a user’s browser into making an unwanted request to a site where the user is already authenticated. The browser automatically includes the user’s cookies (session, JWT).Attack scenario:
  1. User is logged into bank.com (session cookie is set).
  2. User visits evil-site.com which has: <img src="https://bank.com/transfer?to=attacker&amount=10000">.
  3. Browser sends the request to bank.com WITH the user’s session cookie.
  4. bank.com processes the transfer because the cookie is valid.
Defenses:
  1. SameSite Cookie Attribute (strongest, simplest):
res.cookie('session', sessionId, {
    httpOnly: true,
    secure: true,
    sameSite: 'Strict'  // Cookie only sent on same-site requests
    // 'Lax' allows GET navigations (clicking a link) but blocks POST from other sites
});
  1. CSRF Tokens (traditional approach):
const csrf = require('csurf');
app.use(csrf({ cookie: true }));

// In form: include hidden input with _csrf value
// Token is validated on submission -- attacker can't know the token
  1. Double Submit Cookie: Send CSRF token in both a cookie and a request header. Server checks they match. Works because evil-site.com can trigger sending cookies but cannot read them or set custom headers on cross-origin requests.
  2. Origin/Referer Header Validation: Check that the Origin or Referer header matches your domain. Less reliable (can be stripped by proxies).
Modern reality: With SameSite=Lax as the default in modern browsers (Chrome 80+), CSRF attacks are largely mitigated for POST requests. But you should still use CSRF tokens for defense in depth, especially for older browser support.What interviewers are really testing: Do you understand the attack mechanism? Do you know SameSite cookies are the modern primary defense?Red flag answer: “CSRF is the same as XSS.” They’re completely different attacks — XSS executes code in the victim’s browser, CSRF tricks the browser into making authenticated requests.Follow-up:
  • “With SameSite=Strict, what breaks? Why might you choose Lax instead?”
  • “How does CSRF differ from XSS? Can you combine them for a more powerful attack?”
  • “Is CSRF possible with JWT authentication stored in localStorage instead of cookies?”
Answer: Helmet is a collection of 15+ middleware functions that set HTTP security headers. It’s the fastest security win for any Express app — a single line of code addresses multiple attack vectors.
const helmet = require('helmet');
app.use(helmet()); // Enables all default protections
Key headers Helmet sets:
HeaderPurposeDefault
Content-Security-PolicyPrevents XSS by restricting resource sourcesStrict default
Strict-Transport-Security (HSTS)Forces HTTPS for future requestsmax-age=15552000
X-Content-Type-OptionsPrevents MIME type sniffingnosniff
X-Frame-OptionsPrevents clickjacking via iframesSAMEORIGIN
X-XSS-ProtectionLegacy XSS filter (modern browsers use CSP)0 (disabled — can cause issues)
Referrer-PolicyControls what’s sent in the Referer headerno-referrer
X-DNS-Prefetch-ControlDisables DNS prefetching (privacy)off
Customization (you should customize CSP for your app):
app.use(helmet({
    contentSecurityPolicy: {
        directives: {
            defaultSrc: ["'self'"],
            scriptSrc: ["'self'", "cdn.example.com"],
            styleSrc: ["'self'", "'unsafe-inline'"],   // Often needed for CSS-in-JS
            imgSrc: ["'self'", "data:", "*.amazonaws.com"],
            connectSrc: ["'self'", "api.example.com"],
        }
    },
    crossOriginEmbedderPolicy: false, // Disable if you embed third-party resources
}));
What interviewers are really testing: Can you name specific security headers and what they protect against? Do you customize Helmet or just use defaults?Red flag answer: “I add app.use(helmet()) and security is handled.” Helmet is a starting point, not complete security.Follow-up:
  • “What is HSTS and what problem does it solve? What happens if you set HSTS and then lose your TLS certificate?”
  • “What is clickjacking and how does X-Frame-Options prevent it?”
  • “If you’re using a CDN for your JavaScript, how do you configure CSP to allow it while still preventing XSS?”
Answer: Rate limiting controls how many requests a client can make in a time window, protecting against DDoS, brute-force attacks, and API abuse.Strategies:
  1. Fixed Window: Count requests in fixed time windows (e.g., 100 requests per 15 minutes). Simple but has burst problems at window boundaries — a client can send 100 requests at 14:59 and 100 at 15:00 = 200 in 2 minutes.
  2. Sliding Window: Calculates rate based on a sliding window. Smoother than fixed window. express-rate-limit uses this.
  3. Token Bucket: Tokens accumulate at a fixed rate (e.g., 10/sec). Each request consumes a token. If no tokens, request is rejected. Allows bursts up to the bucket size. Used by AWS, Stripe, most production APIs.
  4. Leaky Bucket: Requests enter a queue (bucket) and are processed at a fixed rate. Excess requests overflow (rejected). Smooths traffic to a constant rate.
Implementation layers (defense in depth):
[CDN/WAF Rate Limit] --> [API Gateway/Nginx] --> [Application Rate Limit] --> [Per-Route Limits]
Application-level with Redis (for distributed systems):
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const Redis = require('ioredis');

const limiter = rateLimit({
    store: new RedisStore({
        sendCommand: (...args) => redisClient.call(...args),
    }),
    windowMs: 15 * 60 * 1000,  // 15 minutes
    max: 100,                    // 100 requests per window
    standardHeaders: true,       // Return rate limit info in RateLimit-* headers
    legacyHeaders: false,
    keyGenerator: (req) => req.ip, // Or req.user.id for authenticated rate limiting
    handler: (req, res) => {
        res.status(429).json({
            error: 'Too Many Requests',
            retryAfter: Math.ceil(req.rateLimit.resetTime / 1000)
        });
    }
});

app.use('/api/', limiter);
Why Redis is essential: With express-rate-limit using memory store and Cluster mode with 8 workers, the actual rate limit is 8x what you configured (each worker tracks independently). Redis provides a single shared counter.What interviewers are really testing: Do you know the difference between rate limiting strategies? Do you understand the distributed system implications?Red flag answer: “I use express-rate-limit with default memory store in production.” This breaks with multiple processes/servers.Follow-up:
  • “What’s the difference between Token Bucket and Leaky Bucket? When would you choose one over the other?”
  • “How do you rate limit by API key for authenticated users vs by IP for anonymous users? What about users behind a NAT?”
  • “You rate limit at 100 req/min, but an attacker uses 1000 different IPs. How do you handle this?”
Answer: Unit testing in Node.js means testing functions/modules in isolation by mocking their dependencies.Testing pyramid for Node backends:
  1. Unit tests (70%): Individual functions, service methods, utility functions. Fast, no I/O.
  2. Integration tests (20%): Multiple modules together, often hitting a real test database.
  3. End-to-end tests (10%): Full HTTP request lifecycle with supertest.
Jest vs Mocha:
  • Jest: Zero-config, built-in mocking, snapshot testing, parallel test runner, code coverage. The default choice for most Node projects.
  • Mocha: More flexible, needs separate assertion (Chai) and mocking (Sinon) libraries. Preferred in some enterprise environments.
Testing patterns for Node services:
// Service with a dependency
class UserService {
    constructor(db, emailService) {
        this.db = db;
        this.emailService = emailService;
    }
    
    async createUser(data) {
        const user = await this.db.users.create(data);
        await this.emailService.sendWelcome(user.email);
        return user;
    }
}

// Test with mocks
describe('UserService', () => {
    let service, mockDb, mockEmail;
    
    beforeEach(() => {
        mockDb = { users: { create: jest.fn() } };
        mockEmail = { sendWelcome: jest.fn() };
        service = new UserService(mockDb, mockEmail);
    });
    
    it('creates user and sends welcome email', async () => {
        const userData = { name: 'Alice', email: 'alice@test.com' };
        mockDb.users.create.mockResolvedValue({ id: 1, ...userData });
        
        const result = await service.createUser(userData);
        
        expect(mockDb.users.create).toHaveBeenCalledWith(userData);
        expect(mockEmail.sendWelcome).toHaveBeenCalledWith('alice@test.com');
        expect(result.id).toBe(1);
    });
    
    it('does not send email if user creation fails', async () => {
        mockDb.users.create.mockRejectedValue(new Error('Duplicate email'));
        
        await expect(service.createUser({})).rejects.toThrow('Duplicate email');
        expect(mockEmail.sendWelcome).not.toHaveBeenCalled();
    });
});
Common mistakes:
  • Testing implementation details instead of behavior (checking which internal methods were called vs verifying the output).
  • Not testing error paths (only happy path coverage).
  • Mocking too much — if everything is mocked, you’re testing your mocks, not your code.
What interviewers are really testing: Do you write tests that test behavior, not implementation? Do you mock at the right boundaries?Red flag answer: “I test by running the app manually and checking the output.” No automated testing means no confidence in changes.Follow-up:
  • “What’s the difference between a mock, a stub, and a spy? When do you use each?”
  • “How do you test an Express route handler? What is supertest?”
  • “When is mocking harmful? Give an example where too much mocking hid a real bug.”
Answer: Dependency Injection (DI) means passing dependencies into a function/class from the outside rather than creating them internally. This is the single most important pattern for testable Node.js code.Without DI (hard to test):
const db = require('./database');        // Tight coupling
const mailer = require('./mailer');

async function createUser(data) {
    const user = await db.users.create(data);  // How do you mock db in tests?
    await mailer.send(user.email, 'Welcome');
    return user;
}
With DI (testable):
function createUserService(db, mailer) {
    return {
        async createUser(data) {
            const user = await db.users.create(data);
            await mailer.send(user.email, 'Welcome');
            return user;
        }
    };
}

// Production
const service = createUserService(realDb, realMailer);

// Test
const service = createUserService(mockDb, mockMailer);
DI approaches in Node:
  1. Constructor injection (classes): new Service(db, cache, logger). Most explicit.
  2. Factory functions: createService({ db, cache }). Flexible, functional style.
  3. DI containers (InversifyJS, tsyringe, awilix): Automatic resolution of dependencies. More magic but handles complex graphs.
// Using awilix (popular Node.js DI container)
const awilix = require('awilix');
const container = awilix.createContainer();

container.register({
    db: awilix.asFunction(createDbPool).singleton(),
    userService: awilix.asClass(UserService).scoped(),
    orderService: awilix.asClass(OrderService).scoped(),
});

// Resolves all dependencies automatically
const userService = container.resolve('userService');
What interviewers are really testing: Do you design for testability? Can you explain DI without it being “just passing arguments”?Red flag answer: “I use jest.mock() to mock the require calls.” This works but is a testing workaround for bad design — proper DI is a better architectural solution.Follow-up:
  • “What are the trade-offs of using a DI container vs manual dependency injection in Node?”
  • “How does DI relate to the SOLID principles, specifically the Dependency Inversion Principle?”
  • “In Express, how would you inject dependencies into route handlers without a DI container?”
Answer: TDD is a development methodology: write the test first, watch it fail, write the minimal code to pass, then refactor. The cycle is Red -> Green -> Refactor.The cycle:
  1. Red: Write a test for the next piece of functionality. Run it — it fails (because the code doesn’t exist yet).
  2. Green: Write the minimum code to make the test pass. No more, no less.
  3. Refactor: Clean up the code (remove duplication, improve naming) while keeping tests green.
Example — building a URL shortener:
// Step 1 (RED): Write the test first
describe('shortenUrl', () => {
    it('returns a 6-character code', () => {
        const result = shortenUrl('https://example.com/very/long/path');
        expect(result.code).toHaveLength(6);
    });
    
    it('returns the same code for the same URL', () => {
        const result1 = shortenUrl('https://example.com');
        const result2 = shortenUrl('https://example.com');
        expect(result1.code).toBe(result2.code);
    });
    
    it('rejects invalid URLs', () => {
        expect(() => shortenUrl('not-a-url')).toThrow('Invalid URL');
    });
});

// Step 2 (GREEN): Write minimal code
function shortenUrl(url) {
    if (!url.startsWith('http')) throw new Error('Invalid URL');
    const hash = crypto.createHash('md5').update(url).digest('hex');
    return { code: hash.substring(0, 6) };
}

// Step 3 (REFACTOR): Improve the URL validation, add URL class parsing, etc.
When TDD works well: Pure functions, business logic, utility functions, data transformations, algorithms.When TDD is harder: UI code, integration with external services, rapid prototyping where requirements are unclear.What interviewers are really testing: Do you practice TDD or just know the theory? Can you articulate when it helps and when it hurts?Red flag answer: “I always write tests after the code is done” (not TDD, just testing) or “TDD is too slow for real-world development” (shows rigidity — TDD is a tool, not a religion).Follow-up:
  • “Walk me through how you’d TDD an endpoint that creates a user, hashes their password, and sends a welcome email.”
  • “What’s the difference between TDD and BDD (Behavior Driven Development)?”
  • “When would you choose NOT to use TDD? What are its costs?”
Answer: Environment variables separate configuration from code — the same application binary behaves differently based on the environment it runs in.The dotenv pattern:
// .env file (NEVER commit to git!)
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
JWT_SECRET=supersecretkey123
REDIS_URL=redis://localhost:6379
NODE_ENV=development

// app.js
require('dotenv').config(); // Loads .env into process.env
const dbUrl = process.env.DATABASE_URL;
Best practices:
  1. Never commit secrets to git: Add .env to .gitignore. Use .env.example (with placeholder values) as a template.
  2. Validate env vars at startup: Fail fast if required variables are missing.
const required = ['DATABASE_URL', 'JWT_SECRET', 'REDIS_URL'];
for (const key of required) {
    if (!process.env[key]) {
        console.error(`Missing required env var: ${key}`);
        process.exit(1);
    }
}
  1. Use typed/validated config (not raw process.env everywhere):
// config.js -- single source of truth
const config = {
    port: parseInt(process.env.PORT, 10) || 3000,
    db: {
        url: process.env.DATABASE_URL,
        poolSize: parseInt(process.env.DB_POOL_SIZE, 10) || 10,
    },
    jwt: {
        secret: process.env.JWT_SECRET,
        expiresIn: process.env.JWT_EXPIRES_IN || '1h',
    },
    isProduction: process.env.NODE_ENV === 'production',
};
module.exports = config;
  1. In production, use secrets managers (not .env files): AWS Secrets Manager, HashiCorp Vault, Kubernetes Secrets. .env files are fine for development.
What interviewers are really testing: Do you validate env vars at startup? Do you use a secrets manager in production or just .env files?Red flag answer: “I hardcode the database password in my config file and just change it on each server.” This is a security incident waiting to happen.Follow-up:
  • “What’s the difference between .env, .env.local, .env.production? How do they layer?”
  • “How do you manage secrets in a Kubernetes deployment? What are Kubernetes Secrets and their limitations?”
  • “What happens if process.env.PORT is the string 'undefined'? How does your validation catch this?”
Answer: Production logging in Node requires structured, leveled, performant logging — not console.log.Why console.log is bad for production:
  • Synchronous when writing to a TTY (terminal) — blocks the event loop.
  • No log levels — can’t filter debug messages in production.
  • No structureconsole.log('User created', userId) is not machine-parseable.
  • No context — which request triggered this log? Which user?
Production logging stack:
// Pino (fastest Node.js logger -- 5-10x faster than Winston)
const pino = require('pino');

const logger = pino({
    level: process.env.LOG_LEVEL || 'info',
    // In production: JSON to stdout (let the log aggregator handle formatting)
    // In development: pretty-print for readability
    ...(process.env.NODE_ENV !== 'production' && {
        transport: { target: 'pino-pretty' }
    })
});

// Structured logging with context
logger.info({ userId: 123, orderId: 'abc-456', duration: 42 }, 'Order created');
// Output: {"level":30,"time":1234567890,"userId":123,"orderId":"abc-456","duration":42,"msg":"Order created"}
Log levels (from most to least severe):
  • fatal: App is crashing, needs immediate attention.
  • error: Something failed but the app continues.
  • warn: Unexpected situation, but handled.
  • info: Normal business events (user created, payment processed).
  • debug: Development-time detail.
  • trace: Very verbose, rarely used in production.
Correlation IDs (essential for microservices):
const { randomUUID } = require('crypto');

app.use((req, res, next) => {
    req.id = req.headers['x-request-id'] || randomUUID();
    req.log = logger.child({ requestId: req.id, method: req.method, url: req.url });
    next();
});

// Now every log in the request lifecycle has the requestId
app.get('/users/:id', (req, res) => {
    req.log.info({ userId: req.params.id }, 'Fetching user');
    // {"requestId":"abc-123","method":"GET","url":"/users/42","userId":"42","msg":"Fetching user"}
});
Log pipeline: Application -> stdout -> Log collector (Fluentd/Vector/Filebeat) -> Log aggregator (ELK/Datadog/Grafana Loki) -> Dashboards/Alerts.What interviewers are really testing: Do you use structured logging? Do you know about correlation IDs? Can you explain why Pino is faster than Winston?Red flag answer: “I use console.log and redirect stdout to a file.” No structure, no levels, no correlation, and file I/O issues.Follow-up:
  • “Why is Pino faster than Winston? What architectural decision makes the difference?”
  • “How do you trace a single request across 5 microservices? What headers/IDs do you propagate?”
  • “You’re generating 10GB of logs per day. How do you manage log storage, rotation, and retention?“

6. Node.js Medium Level Questions

Answer: Middleware executes strictly in the order it is defined. This is not a detail — it is fundamental to how Express works, and getting it wrong causes subtle bugs.
app.use(helmet());                    // 1. Security headers (FIRST -- before any response)
app.use(cors(corsOptions));           // 2. CORS (before body parsing)
app.use(compression());              // 3. Compress responses
app.use(express.json({ limit: '10mb' })); // 4. Parse JSON bodies
app.use(morgan('combined'));          // 5. Log requests (after body parsing so we can log body size)
app.use(authMiddleware);              // 6. Authenticate
app.get('/api/users', handler);       // 7. Route handler
app.use(notFoundHandler);             // 8. 404 for unmatched routes
app.use(errorHandler);                // 9. Error handling (MUST BE LAST)
Common ordering bugs:
  • Putting errorHandler before routes: errors thrown in routes never reach it.
  • Putting authMiddleware after routes: routes execute unauthenticated.
  • Putting express.json() after routes: req.body is undefined in handlers.
  • Putting cors() after routes: preflight OPTIONS requests get 404.
What interviewers are really testing: Can you reason about why order matters and diagnose ordering bugs?Red flag answer: “I just put middleware in whatever order and it works.” It works until it doesn’t, and the bugs are subtle.Follow-up:
  • “You add express.json() but req.body is always undefined. What went wrong?”
  • “Should compression() go before or after express.static()? Why?”
Answer:
// Route params: /users/:id -- part of the URL path, required
app.get('/users/:id', (req, res) => {
    const id = req.params.id; // Always a string! Must parse for numbers.
});

// Multiple params: /users/:userId/posts/:postId
app.get('/users/:userId/posts/:postId', (req, res) => {
    const { userId, postId } = req.params;
});

// Query strings: /search?q=node&limit=10 -- optional filters
app.get('/search', (req, res) => {
    const { q, limit = '20', page = '1' } = req.query; // All strings!
    const parsedLimit = Math.min(parseInt(limit, 10), 100); // Always validate/cap
});

// Optional params with regex
app.get('/files/:path(*)', (req, res) => {
    // Matches /files/a/b/c.txt -- req.params.path = 'a/b/c.txt'
});
Key gotcha: Both req.params and req.query values are always strings. req.params.id is "42", not 42. Always parse and validate.Security consideration: Query strings can be manipulated. Never trust req.query.isAdmin without server-side authorization.What interviewers are really testing: Do you know params are strings? Do you validate and sanitize?Follow-up:
  • “What is req.params vs req.query vs req.body? When do you use each?”
  • “How would you handle a route parameter that must be a valid MongoDB ObjectId?”
Answer:
// JSON bodies (Content-Type: application/json)
app.use(express.json({ limit: '10mb' }));

// URL-encoded bodies (Content-Type: application/x-www-form-urlencoded)
app.use(express.urlencoded({ extended: true }));
// extended: true -> uses `qs` library (nested objects: user[name]=foo)
// extended: false -> uses `querystring` (flat only: user=foo)

// Multipart form data (file uploads)
const multer = require('multer');
const upload = multer({
    dest: 'uploads/',
    limits: { fileSize: 5 * 1024 * 1024 } // 5MB limit
});
app.post('/upload', upload.single('avatar'), (req, res) => {
    // req.file contains upload metadata
    // req.body contains text fields
});
What happens without body parsing: req.body is undefined. This is the number one “Express isn’t working” complaint from beginners.What interviewers are really testing: Do you always set size limits? Do you understand the different content types?Follow-up:
  • “What’s the difference between extended: true and extended: false? When does it matter?”
  • “How does body parsing relate to streams internally?”
Answer:
// 1. Async error wrapper (Express 4 requirement)
const asyncHandler = (fn) => (req, res, next) =>
    Promise.resolve(fn(req, res, next)).catch(next);

// 2. Custom error classes for consistent error responses
class AppError extends Error {
    constructor(message, statusCode) {
        super(message);
        this.statusCode = statusCode;
        this.isOperational = true; // Expected error, not a bug
    }
}

class NotFoundError extends AppError {
    constructor(resource = 'Resource') {
        super(`${resource} not found`, 404);
    }
}

class ValidationError extends AppError {
    constructor(message) {
        super(message, 400);
    }
}

// 3. Route handlers throw custom errors
app.get('/users/:id', asyncHandler(async (req, res) => {
    const user = await db.users.findById(req.params.id);
    if (!user) throw new NotFoundError('User');
    res.json(user);
}));

// 4. Centralized error handler (LAST middleware)
app.use((err, req, res, next) => {
    // Log full error for debugging
    req.log?.error({ err, url: req.originalUrl }, 'Request error');
    
    // Operational errors: send message to client
    if (err.isOperational) {
        return res.status(err.statusCode).json({ error: err.message });
    }
    
    // Programming errors: don't leak internals
    res.status(500).json({
        error: 'Internal Server Error',
        ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
    });
});
What interviewers are really testing: Do you distinguish between operational errors (expected, e.g., 404) and programmer errors (bugs)? Do you have a centralized error handler?Follow-up:
  • “What’s the difference between an operational error and a programmer error? How do you handle each differently?”
  • “In Express 5, do you still need the asyncHandler wrapper?”
Answer:
const cors = require('cors');

// DEVELOPMENT: Allow all (quick and dirty)
app.use(cors());

// PRODUCTION: Whitelist specific origins
const allowedOrigins = [
    'https://app.example.com',
    'https://admin.example.com',
    process.env.NODE_ENV === 'development' && 'http://localhost:3000'
].filter(Boolean);

app.use(cors({
    origin: (origin, callback) => {
        // Allow requests with no origin (mobile apps, curl, server-to-server)
        if (!origin) return callback(null, true);
        
        if (allowedOrigins.includes(origin)) {
            callback(null, true);
        } else {
            callback(new Error(`Origin ${origin} not allowed by CORS`));
        }
    },
    credentials: true,                        // Allow cookies
    methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
    allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
    exposedHeaders: ['X-Total-Count'],        // Headers the client can read
    maxAge: 86400,                            // Cache preflight for 24 hours
}));
Dynamic origin is important because origin: '*' cannot be used with credentials: true. The function-based approach lets you whitelist multiple origins while still supporting credentials.What interviewers are really testing: Do you use dynamic origin checking in production?Follow-up:
  • “Why does the function receive origin: undefined for some requests? Which requests have no origin?”
  • “How do you debug CORS errors? Where do you look?”
Answer:
const rateLimit = require('express-rate-limit');

// General API rate limit
const apiLimiter = rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 100,                  // 100 requests per window per IP
    standardHeaders: true,     // Return rate limit info in RateLimit-* headers
    legacyHeaders: false,      // Disable X-RateLimit-* headers
    message: { error: 'Too many requests, please try again later.' }
});

// Strict limit for auth endpoints (prevent brute force)
const authLimiter = rateLimit({
    windowMs: 15 * 60 * 1000,
    max: 5,                    // Only 5 login attempts per 15 min
    skipSuccessfulRequests: true // Don't count successful logins
});

app.use('/api/', apiLimiter);
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/register', authLimiter);
Important: In production with multiple servers, use a Redis store (see question 45 for details).What interviewers are really testing: Do you apply different limits to different endpoints? Do you know about skipSuccessfulRequests?Follow-up:
  • “How do you handle rate limiting for authenticated users vs anonymous users?”
  • “What’s the RateLimit-* header standard (RFC 6585) and how does the client use it?”
Answer: Never trust client input. Validate every field for type, format, range, and length.
// Using express-validator (declarative, middleware-based)
const { body, param, query, validationResult } = require('express-validator');

app.post('/users',
    body('email').isEmail().normalizeEmail(),
    body('password').isLength({ min: 8 }).matches(/[A-Z]/).matches(/[0-9]/),
    body('age').optional().isInt({ min: 0, max: 150 }),
    body('name').trim().notEmpty().escape(), // escape() prevents XSS
    (req, res) => {
        const errors = validationResult(req);
        if (!errors.isEmpty()) {
            return res.status(400).json({
                errors: errors.array().map(e => ({
                    field: e.path,
                    message: e.msg,
                    value: e.value
                }))
            });
        }
        // req.body is now validated and sanitized
    }
);

// Alternative: Zod (TypeScript-first, schema-based)
const { z } = require('zod');

const createUserSchema = z.object({
    email: z.string().email(),
    password: z.string().min(8).regex(/[A-Z]/).regex(/[0-9]/),
    age: z.number().int().min(0).max(150).optional(),
    name: z.string().min(1).max(100),
});

app.post('/users', (req, res) => {
    const result = createUserSchema.safeParse(req.body);
    if (!result.success) {
        return res.status(400).json({ errors: result.error.flatten() });
    }
    const validData = result.data; // Fully typed and validated
});
What interviewers are really testing: Do you validate on the server even if the client validates? Do you sanitize in addition to validating?Red flag answer: “I validate on the frontend so the backend doesn’t need to.” Client-side validation is UX — server-side validation is security.Follow-up:
  • “What’s the difference between validation (is this valid?) and sanitization (make this safe)?”
  • “How does Zod compare to express-validator? When would you choose one over the other?”
Answer:
// PostgreSQL with pg
const { Pool } = require('pg');
const pool = new Pool({
    connectionString: process.env.DATABASE_URL,
    max: 20,                        // Max connections in pool
    idleTimeoutMillis: 30000,       // Close idle connections after 30s
    connectionTimeoutMillis: 5000,  // Fail if can't get connection in 5s
    ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false
});

// Proper usage: let the pool manage connections
const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);

// For transactions: checkout a client, run queries, release
const client = await pool.connect();
try {
    await client.query('BEGIN');
    await client.query('INSERT INTO orders ...', [orderData]);
    await client.query('UPDATE inventory SET stock = stock - 1 WHERE id = $1', [itemId]);
    await client.query('COMMIT');
} catch (err) {
    await client.query('ROLLBACK');
    throw err;
} finally {
    client.release(); // CRITICAL: always release back to pool!
}
The client.release() trap: If you forget to release a client, it’s “leaked” — the pool thinks it’s still in use. After max leaks, all new queries hang forever waiting for a connection.What interviewers are really testing: Do you handle transactions correctly? Do you always release connections?Follow-up:
  • “What happens if you forget to call client.release() after checking out a client from the pool?”
  • “How do you monitor pool health to detect connection leaks?”
Answer:
const mongoose = require('mongoose');

// Connection with production options
await mongoose.connect(process.env.MONGODB_URI, {
    maxPoolSize: 10,          // Connection pool size
    serverSelectionTimeoutMS: 5000,
    socketTimeoutMS: 45000,
});

// Schema with validation, indexes, virtuals
const userSchema = new mongoose.Schema({
    name: { type: String, required: true, trim: true },
    email: { type: String, required: true, unique: true, lowercase: true },
    password: { type: String, required: true, select: false }, // Excluded from queries by default
    role: { type: String, enum: ['user', 'admin'], default: 'user' },
    loginAttempts: { type: Number, default: 0 },
}, {
    timestamps: true, // createdAt, updatedAt
    toJSON: { virtuals: true }
});

// Index for performance
userSchema.index({ email: 1 });
userSchema.index({ createdAt: -1 });

// Pre-save hook (hash password)
userSchema.pre('save', async function(next) {
    if (!this.isModified('password')) return next();
    this.password = await bcrypt.hash(this.password, 12);
    next();
});

// Instance method
userSchema.methods.comparePassword = async function(candidatePassword) {
    return bcrypt.compare(candidatePassword, this.password);
};

const User = mongoose.model('User', userSchema);
What interviewers are really testing: Do you use schemas with validation? Do you know about pre/post hooks, select: false, and indexing?Follow-up:
  • “What’s the difference between Mongoose validation and MongoDB schema validation? When would you use each?”
  • “How do you handle the unique constraint in Mongoose — is it a validator or an index?”
Answer:
const jwt = require('jsonwebtoken');

// Token generation with proper claims
function generateTokens(user) {
    const accessToken = jwt.sign(
        { sub: user.id, role: user.role },
        process.env.JWT_ACCESS_SECRET,
        { expiresIn: '15m', issuer: 'api.example.com' }
    );
    
    const refreshToken = jwt.sign(
        { sub: user.id, tokenVersion: user.tokenVersion },
        process.env.JWT_REFRESH_SECRET,
        { expiresIn: '7d' }
    );
    
    return { accessToken, refreshToken };
}

// Auth middleware with proper error handling
function authenticate(req, res, next) {
    const authHeader = req.headers.authorization;
    if (!authHeader?.startsWith('Bearer ')) {
        return res.status(401).json({ error: 'No token provided' });
    }
    
    const token = authHeader.split(' ')[1];
    try {
        const payload = jwt.verify(token, process.env.JWT_ACCESS_SECRET, {
            issuer: 'api.example.com'
        });
        req.user = { id: payload.sub, role: payload.role };
        next();
    } catch (err) {
        if (err.name === 'TokenExpiredError') {
            return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
        }
        return res.status(401).json({ error: 'Invalid token' });
    }
}

// Role-based authorization
function authorize(...roles) {
    return (req, res, next) => {
        if (!roles.includes(req.user.role)) {
            return res.status(403).json({ error: 'Insufficient permissions' });
        }
        next();
    };
}

app.get('/admin/users', authenticate, authorize('admin'), handler);
What interviewers are really testing: Do you separate authentication from authorization? Do you handle token expiry gracefully?Follow-up:
  • “How do you implement token refresh? What happens if the refresh token is stolen?”
  • “How would you revoke all tokens for a user who changes their password?”
Answer:
const bcrypt = require('bcrypt');

// Hash password -- salt rounds determine computational cost
// 10 rounds ~= 100ms, 12 rounds ~= 300ms, 14 rounds ~= 1s
const SALT_ROUNDS = 12;

async function hashPassword(plaintext) {
    return bcrypt.hash(plaintext, SALT_ROUNDS);
    // Output: "$2b$12$LJ3m4ys3Lk0TH9Kz..." (includes algorithm, cost, salt, and hash)
}

async function verifyPassword(plaintext, hash) {
    return bcrypt.compare(plaintext, hash);
    // bcrypt extracts the salt from the stored hash -- no need to store salt separately
}
Why bcrypt specifically:
  • Adaptive cost: Increase SALT_ROUNDS as hardware gets faster (double cost every 18 months to match Moore’s Law).
  • Built-in salt: Each hash includes a random salt — identical passwords produce different hashes.
  • Intentionally slow: 12 rounds = ~300ms per hash. An attacker trying 10 billion passwords needs ~95 years. MD5/SHA would take minutes.
Alternatives: argon2 (winner of the Password Hashing Competition, recommended for new projects), scrypt (built into Node’s crypto module).What interviewers are really testing: Do you know why bcrypt is slow on purpose? Can you explain adaptive cost?Red flag answer: “I use SHA256 to hash passwords.” SHA is a fast hash designed for integrity checks, not password storage.Follow-up:
  • “Why is bcrypt better than SHA-256 for passwords? They’re both hash functions.”
  • “What salt rounds would you choose and how do you decide when to increase them?”
Answer:
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');

// Production storage config
const storage = multer.diskStorage({
    destination: (req, file, cb) => {
        cb(null, path.join(__dirname, 'uploads'));
    },
    filename: (req, file, cb) => {
        // Generate random filename to prevent collisions and path traversal
        const ext = path.extname(file.originalname);
        const name = crypto.randomBytes(16).toString('hex');
        cb(null, `${name}${ext}`);
    }
});

// File filter with MIME type validation
const fileFilter = (req, file, cb) => {
    const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
    if (allowedTypes.includes(file.mimetype)) {
        cb(null, true);
    } else {
        cb(new multer.MulterError('LIMIT_UNEXPECTED_FILE', 'Invalid file type'), false);
    }
};

const upload = multer({
    storage,
    fileFilter,
    limits: {
        fileSize: 5 * 1024 * 1024, // 5MB
        files: 5                     // Max 5 files per request
    }
});

// Error handling for multer
app.post('/upload', (req, res, next) => {
    upload.single('avatar')(req, res, (err) => {
        if (err instanceof multer.MulterError) {
            return res.status(400).json({ error: err.message });
        }
        if (err) return next(err);
        
        if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
        res.json({ filename: req.file.filename, size: req.file.size });
    });
});
What interviewers are really testing: Do you validate file types, set size limits, and generate random filenames?Follow-up:
  • “How would you stream uploads directly to S3 without saving to disk?”
  • “Why do you generate random filenames instead of using the original filename?”
Answer:
const nodemailer = require('nodemailer');

// Production: use a dedicated email service (SendGrid, SES, Mailgun)
const transporter = nodemailer.createTransport({
    host: process.env.SMTP_HOST,       // e.g., smtp.sendgrid.net
    port: 587,
    secure: false,                      // true for 465, false for other ports
    auth: {
        user: process.env.SMTP_USER,
        pass: process.env.SMTP_PASS
    },
    pool: true,                         // Reuse connections
    maxConnections: 5,
    maxMessages: 100                    // Max messages per connection
});

// Send with proper error handling and retry
async function sendEmail(to, subject, html) {
    try {
        const info = await transporter.sendMail({
            from: '"MyApp" <noreply@example.com>',
            to,
            subject,
            html,
            text: htmlToText(html)      // Always include plaintext fallback
        });
        logger.info({ messageId: info.messageId }, 'Email sent');
        return info;
    } catch (err) {
        logger.error({ err, to, subject }, 'Email send failed');
        throw err;
    }
}
Production pattern: Never send emails synchronously in a request handler. Use a job queue:
// In request handler
await emailQueue.add('welcome-email', { userId: user.id, email: user.email });

// In worker (separate process)
emailQueue.process('welcome-email', async (job) => {
    await sendEmail(job.data.email, 'Welcome!', welcomeTemplate(job.data));
});
What interviewers are really testing: Do you send emails asynchronously via a queue? Do you use connection pooling?Follow-up:
  • “Why should emails be sent via a queue instead of directly in the request handler?”
  • “How do you handle email delivery failures and retries?”
Answer:
// Unit test with good patterns
describe('UserService', () => {
    let service;
    
    beforeEach(() => {
        service = createUserService(mockDb, mockMailer);
        jest.clearAllMocks(); // Reset mock call counts between tests
    });
    
    describe('createUser', () => {
        it('creates user with hashed password', async () => {
            const input = { email: 'test@test.com', password: 'Pass123!' };
            mockDb.users.create.mockResolvedValue({ id: 1, ...input });
            
            const user = await service.createUser(input);
            
            expect(user.id).toBe(1);
            expect(mockDb.users.create).toHaveBeenCalledTimes(1);
        });
        
        it('rejects duplicate emails', async () => {
            mockDb.users.create.mockRejectedValue(new Error('Duplicate key'));
            
            await expect(service.createUser({ email: 'dup@test.com' }))
                .rejects.toThrow('Duplicate key');
        });
    });
});

// Integration test with supertest
const request = require('supertest');
const app = require('./app');

describe('POST /api/users', () => {
    it('returns 201 with valid data', async () => {
        const res = await request(app)
            .post('/api/users')
            .send({ email: 'test@test.com', password: 'Pass123!' })
            .expect(201);
        
        expect(res.body).toHaveProperty('id');
        expect(res.body.email).toBe('test@test.com');
    });
    
    it('returns 400 with invalid email', async () => {
        await request(app)
            .post('/api/users')
            .send({ email: 'invalid', password: 'Pass123!' })
            .expect(400);
    });
});
What interviewers are really testing: Do you test both happy and error paths? Do you use beforeEach to reset state?Follow-up:
  • “How do you test a route that requires authentication? How do you mock the auth middleware?”
  • “What’s the difference between unit testing a service and integration testing an endpoint?”
Answer:
// 1. Mock function (spy on calls, control return values)
const mockFn = jest.fn();
mockFn.mockReturnValue(42);
mockFn.mockResolvedValue({ data: 'async result' });
mockFn.mockImplementation((x) => x * 2);

// 2. Mock an entire module
jest.mock('./database');
const db = require('./database');
db.query.mockResolvedValue([{ id: 1, name: 'Alice' }]);

// 3. Partial mock (keep some real implementations)
jest.mock('./utils', () => ({
    ...jest.requireActual('./utils'),  // Keep real implementations
    sendEmail: jest.fn()                // Only mock this one
}));

// 4. Mock timers (for setTimeout/setInterval tests)
jest.useFakeTimers();
setTimeout(callback, 1000);
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalled();

// 5. Spy on existing methods (without replacing)
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
// ... test code that logs errors ...
expect(spy).toHaveBeenCalledWith(expect.stringContaining('failed'));
spy.mockRestore(); // Clean up
Mock vs Stub vs Spy:
  • Mock: Replace a function entirely, control its behavior and verify calls.
  • Stub: A mock with pre-programmed return values (focused on output, not call verification).
  • Spy: Wraps the real function, records calls but still executes the original.
What interviewers are really testing: Do you know when to use jest.mock vs jest.spyOn? Can you mock at the right granularity?Follow-up:
  • “When would you use jest.spyOn instead of jest.mock?”
  • “How do you mock a function that’s imported using ES module named exports?”
Answer:
// Load at the very top of entry point
require('dotenv').config();

// Typed, validated config module
const config = {
    port: parseInt(process.env.PORT, 10) || 3000,
    nodeEnv: process.env.NODE_ENV || 'development',
    db: {
        url: process.env.DATABASE_URL,
        poolSize: parseInt(process.env.DB_POOL_SIZE, 10) || 10,
    },
    redis: {
        url: process.env.REDIS_URL || 'redis://localhost:6379',
    },
    jwt: {
        accessSecret: process.env.JWT_ACCESS_SECRET,
        refreshSecret: process.env.JWT_REFRESH_SECRET,
    },
    get isProduction() { return this.nodeEnv === 'production'; },
    get isDevelopment() { return this.nodeEnv === 'development'; },
};

// Validate required vars
const required = ['DATABASE_URL', 'JWT_ACCESS_SECRET'];
const missing = required.filter(key => !process.env[key]);
if (missing.length > 0) {
    throw new Error(`Missing required env vars: ${missing.join(', ')}`);
}

module.exports = config;
Always provide .env.example:
# .env.example (committed to git, no real values)
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
JWT_ACCESS_SECRET=change-me-in-production
REDIS_URL=redis://localhost:6379
What interviewers are really testing: Do you validate env vars at startup? Do you centralize config?Follow-up:
  • “How do you handle different configs for development, staging, and production?”
  • “What’s the security risk of logging process.env for debugging?”
Answer:
// Pino (recommended -- 5-10x faster than Winston)
const pino = require('pino');

const logger = pino({
    level: process.env.LOG_LEVEL || 'info',
    serializers: {
        err: pino.stdSerializers.err,    // Properly serialize Error objects
        req: pino.stdSerializers.req,
    },
    redact: ['req.headers.authorization', 'password'], // Strip sensitive fields
});

// Winston (more configurable, slower)
const winston = require('winston');

const logger = winston.createLogger({
    level: 'info',
    format: winston.format.combine(
        winston.format.timestamp(),
        winston.format.errors({ stack: true }),
        winston.format.json()
    ),
    defaultMeta: { service: 'user-api' },
    transports: [
        new winston.transports.Console(),
        new winston.transports.File({ filename: 'error.log', level: 'error' }),
    ]
});

// Why Pino is faster: it defers JSON serialization to a worker thread
// (pino-worker transport) and writes to stdout asynchronously.
// Winston serializes synchronously on the main thread.
What interviewers are really testing: Do you know why Pino is faster? Do you redact sensitive fields?Follow-up:
  • “Why does Pino recommend writing to stdout instead of files?”
  • “How do you add request context (requestId, userId) to every log line in a request lifecycle?”
Answer:
const morgan = require('morgan');

// Development: concise, colored output
app.use(morgan('dev'));
// GET /api/users 200 12.345 ms

// Production: combined format (Apache-style) piped to logger
app.use(morgan('combined', {
    stream: {
        write: (message) => logger.info(message.trim()) // Pipe to Pino/Winston
    },
    skip: (req, res) => res.statusCode < 400  // Only log errors in production
}));

// Custom format with response time and body size
app.use(morgan(':method :url :status :res[content-length] - :response-time ms'));
Production consideration: Morgan writes to stdout synchronously. For high-throughput APIs (10k+ req/s), consider using Pino’s built-in HTTP logging instead (pino-http), which is asynchronous and integrates with request context.What interviewers are really testing: Do you know about piping Morgan output to a proper logger?Follow-up:
  • “What’s the performance difference between Morgan and pino-http?”
  • “How do you avoid logging sensitive data (passwords, tokens) in request/response logs?”
Answer:
const path = require('path');

// Basic static serving
app.use(express.static(path.join(__dirname, 'public')));

// With prefix
app.use('/assets', express.static(path.join(__dirname, 'public')));

// Production options
app.use(express.static(path.join(__dirname, 'public'), {
    maxAge: '1y',                // Cache static assets for 1 year
    etag: true,                  // Enable ETag for cache validation
    lastModified: true,
    immutable: true,             // Files won't change (use with fingerprinted filenames)
    index: false,                // Don't serve index.html for directories
    dotfiles: 'deny',           // Reject requests for .env, .git, etc.
}));
Production reality: In production, you typically don’t serve static files from Node at all. Use a CDN (CloudFront, Cloudflare) or Nginx reverse proxy. Node’s event loop should be reserved for dynamic API responses, not static file I/O.What interviewers are really testing: Do you know that Node shouldn’t serve static files in production? Do you set proper cache headers?Follow-up:
  • “Why shouldn’t Node.js serve static files in production? What should serve them instead?”
  • “What is the immutable cache directive and when should you use it?“

7. Node.js Advanced Level Questions

Answer: Understanding custom streams shows deep knowledge of Node’s I/O model.
const { Readable, Writable, Transform } = require('stream');

// Custom Readable: generates data
class DatabaseCursor extends Readable {
    constructor(query, options) {
        super({ ...options, objectMode: true }); // Object mode for JS objects
        this.query = query;
        this.offset = 0;
        this.batchSize = 100;
    }
    
    async _read() {
        try {
            const rows = await db.query(this.query, {
                offset: this.offset,
                limit: this.batchSize
            });
            
            if (rows.length === 0) {
                this.push(null); // Signal end of stream
                return;
            }
            
            for (const row of rows) {
                // push returns false when highWaterMark reached (backpressure)
                if (!this.push(row)) break;
            }
            this.offset += rows.length;
        } catch (err) {
            this.destroy(err); // Propagate error
        }
    }
}

// Usage: stream millions of rows with constant memory
const cursor = new DatabaseCursor('SELECT * FROM large_table');
cursor.pipe(transformStream).pipe(outputStream);
Key methods to implement:
  • Readable._read(size): Called when the consumer wants more data. Call this.push(data) or this.push(null) to end.
  • Writable._write(chunk, encoding, callback): Called for each chunk. Call callback() when done, callback(err) on error.
  • Transform._transform(chunk, encoding, callback): Process and push modified data. Call callback() when done.
  • Transform._flush(callback): Called when all input has been consumed. Emit any remaining buffered output.
What interviewers are really testing: Can you implement streams from scratch, not just use built-in ones?Red flag answer: Only knowing how to use fs.createReadStream without understanding how to build custom streams.Follow-up:
  • “What is the _flush method in a Transform stream and when would you use it?”
  • “How do you handle errors in a custom Readable stream? What happens if _read throws?”
  • “When would you use objectMode: true and what changes about the stream’s behavior?”
Answer: Transform streams are the workhorses of data pipelines — they sit between a Readable and Writable, modifying data as it flows through.
const { Transform } = require('stream');

// CSV to JSON transform
class CsvToJson extends Transform {
    constructor(options) {
        super({ ...options, objectMode: true });
        this.headers = null;
        this.buffer = '';
    }
    
    _transform(chunk, encoding, callback) {
        this.buffer += chunk.toString();
        const lines = this.buffer.split('\n');
        this.buffer = lines.pop(); // Keep incomplete last line
        
        for (const line of lines) {
            if (!this.headers) {
                this.headers = line.split(',').map(h => h.trim());
                continue;
            }
            
            const values = line.split(',');
            const obj = {};
            this.headers.forEach((header, i) => {
                obj[header] = values[i]?.trim();
            });
            this.push(obj);
        }
        callback();
    }
    
    _flush(callback) {
        // Process any remaining data in the buffer
        if (this.buffer && this.headers) {
            const values = this.buffer.split(',');
            const obj = {};
            this.headers.forEach((header, i) => {
                obj[header] = values[i]?.trim();
            });
            this.push(obj);
        }
        callback();
    }
}

// Usage: process a 10GB CSV file with constant memory
const { pipeline } = require('stream/promises');

await pipeline(
    fs.createReadStream('massive.csv'),
    new CsvToJson(),
    new Transform({
        objectMode: true,
        transform(record, enc, cb) {
            // Filter and transform each record
            if (record.status === 'active') {
                this.push(JSON.stringify(record) + '\n');
            }
            cb();
        }
    }),
    fs.createWriteStream('output.jsonl')
);
What interviewers are really testing: Can you handle partial data across chunks (the buffer pattern)? Do you implement _flush for remaining data?Follow-up:
  • “What happens if a chunk splits in the middle of a UTF-8 multi-byte character? How do you handle it?”
  • “How would you add parallel processing to a Transform stream (process multiple chunks concurrently)?”
Answer: When you cannot use .pipe() or pipeline(), you need to handle backpressure manually.
const readable = getReadableStream();
const writable = getWritableStream();

// Manual backpressure handling
readable.on('data', (chunk) => {
    const canContinue = writable.write(chunk);
    if (!canContinue) {
        // Writable buffer is full -- STOP reading!
        readable.pause();
        console.log('Backpressure: pausing reader');
    }
});

writable.on('drain', () => {
    // Writable buffer has drained -- RESUME reading
    readable.resume();
    console.log('Drain: resuming reader');
});

writable.on('error', (err) => {
    readable.destroy(); // Clean up on error
    console.error('Write error:', err);
});

readable.on('end', () => {
    writable.end(); // Signal end of writing
});
Why this matters: Without backpressure handling, a fast network read (100MB/s) writing to a slow disk (10MB/s) will buffer 90MB/s in memory. In 10 seconds, you’ve consumed 900MB of RAM. In a minute, you’re OOM.highWaterMark determines when backpressure kicks in:
// Writable with custom buffer size
const writable = new Writable({
    highWaterMark: 1024 * 64,  // 64KB buffer before signaling backpressure
    write(chunk, encoding, callback) {
        // Slow operation...
        setTimeout(callback, 100); // Simulating slow write
    }
});
What interviewers are really testing: Can you implement backpressure without .pipe()? Do you understand highWaterMark?Follow-up:
  • “What is the highWaterMark and how does choosing the wrong value affect performance?”
  • “How does pipeline() handle backpressure differently from .pipe()?”
Answer: Node provides three ways to create child processes, each with different characteristics.
const { spawn, exec, fork } = require('child_process');

// spawn: Stream-based, no shell, best for long-running processes
const ls = spawn('ls', ['-lh', '/var/log']);
ls.stdout.on('data', (data) => console.log(`stdout: ${data}`));
ls.stderr.on('data', (data) => console.error(`stderr: ${data}`));
ls.on('close', (code) => console.log(`Exit code: ${code}`));

// exec: Buffer-based, runs in a shell, best for short commands
// WARNING: maxBuffer defaults to 1MB -- large output causes ENOBUFS
exec('ls -lh /var/log', { maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
    console.log(stdout); // Entire output as a string
});

// fork: Specialized spawn for Node.js processes with IPC channel
const child = fork('./worker.js', [], {
    env: { ...process.env, WORKER_ID: '1' }
});
child.send({ type: 'PROCESS', data: payload });
child.on('message', (msg) => console.log('Worker result:', msg));
When to use each:
MethodShellOutputIPCUse Case
spawnNoStreamNoLong-running processes, large output (ffmpeg, log tailing)
execYesBufferNoShort commands, small output (git status, shell scripts)
forkNoStreamYes (built-in)Node.js worker processes that communicate with parent
Security: exec runs in a shell, making it vulnerable to command injection if user input is interpolated:
// VULNERABLE
exec(`convert ${userFilename} output.png`); // userFilename = "; rm -rf /"

// SAFE: Use spawn (no shell, arguments are separate)
spawn('convert', [userFilename, 'output.png']);
What interviewers are really testing: Do you know the shell injection risk with exec? Can you choose the right method for a given scenario?Red flag answer: Using exec for everything, especially with user-provided input.Follow-up:
  • “Why is exec vulnerable to command injection while spawn is not?”
  • “How does fork’s IPC channel work? What serialization format does it use?”
  • “How would you implement a process pool for CPU-intensive tasks using fork?”
Answer:
const cluster = require('cluster');
const os = require('os');

if (cluster.isPrimary) {
    const numWorkers = parseInt(process.env.WEB_CONCURRENCY, 10) || os.cpus().length;
    console.log(`Primary ${process.pid} starting ${numWorkers} workers`);
    
    for (let i = 0; i < numWorkers; i++) {
        cluster.fork();
    }
    
    // Auto-restart dead workers
    cluster.on('exit', (worker, code, signal) => {
        if (signal !== 'SIGTERM') { // Only restart if not intentional shutdown
            console.error(`Worker ${worker.process.pid} died (${signal || code}). Restarting...`);
            cluster.fork();
        }
    });
    
    // Graceful shutdown
    process.on('SIGTERM', () => {
        console.log('Primary received SIGTERM, shutting down workers');
        for (const id in cluster.workers) {
            cluster.workers[id].process.kill('SIGTERM');
        }
    });
} else {
    // Worker process
    const app = require('./app');
    const server = app.listen(process.env.PORT || 3000, () => {
        console.log(`Worker ${process.pid} listening`);
    });
    
    // Graceful shutdown for worker
    process.on('SIGTERM', () => {
        server.close(() => {
            console.log(`Worker ${process.pid} closed`);
            process.exit(0);
        });
    });
}
Use WEB_CONCURRENCY env var instead of os.cpus().length for container environments where CPU count may not reflect the container’s allocation.What interviewers are really testing: Do you handle graceful shutdown? Do you handle the container CPU issue?Follow-up:
  • “How does the master distribute connections to workers? What load balancing algorithm is used?”
  • “What happens to WebSocket connections when a worker dies?”
Answer:
// main.js -- Worker thread pool pattern
const { Worker } = require('worker_threads');
const os = require('os');

class WorkerPool {
    constructor(workerPath, poolSize = os.cpus().length) {
        this.workerPath = workerPath;
        this.workers = [];
        this.queue = [];
        
        for (let i = 0; i < poolSize; i++) {
            this.addWorker();
        }
    }
    
    addWorker() {
        const worker = new Worker(this.workerPath);
        worker.busy = false;
        worker.on('message', (result) => {
            worker.busy = false;
            worker.currentResolve(result);
            this.processQueue();
        });
        worker.on('error', (err) => {
            worker.busy = false;
            worker.currentReject(err);
            this.processQueue();
        });
        this.workers.push(worker);
    }
    
    runTask(data) {
        return new Promise((resolve, reject) => {
            const idle = this.workers.find(w => !w.busy);
            if (idle) {
                idle.busy = true;
                idle.currentResolve = resolve;
                idle.currentReject = reject;
                idle.postMessage(data);
            } else {
                this.queue.push({ data, resolve, reject });
            }
        });
    }
    
    processQueue() {
        if (this.queue.length === 0) return;
        const idle = this.workers.find(w => !w.busy);
        if (!idle) return;
        
        const { data, resolve, reject } = this.queue.shift();
        idle.busy = true;
        idle.currentResolve = resolve;
        idle.currentReject = reject;
        idle.postMessage(data);
    }
}

// worker.js
const { parentPort } = require('worker_threads');
parentPort.on('message', (data) => {
    // CPU-intensive work here
    const result = heavyComputation(data);
    parentPort.postMessage(result);
});

// Usage
const pool = new WorkerPool('./worker.js', 4);
const result = await pool.runTask({ image: imageBuffer });
What interviewers are really testing: Can you design a worker pool? Do you understand the task queuing and worker lifecycle?Follow-up:
  • “What happens if a worker crashes? How do you make the pool resilient?”
  • “How would you add a timeout to tasks so a hung worker doesn’t block the pool forever?”
Answer:
const { PerformanceObserver, performance } = require('perf_hooks');

// Set up observer for custom measurements
const obs = new PerformanceObserver((items) => {
    for (const entry of items.getEntries()) {
        console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`);
    }
});
obs.observe({ entryTypes: ['measure'] });

// Measure database query time
performance.mark('db-start');
const result = await db.query('SELECT * FROM users');
performance.mark('db-end');
performance.measure('db-query', 'db-start', 'db-end');

// Measure with timerify (auto-wrap functions)
const { performance: perf, createHistogram } = require('perf_hooks');
const wrappedFn = perf.timerify(myFunction);
wrappedFn(); // Automatically records duration

// Histogram for repeated measurements
const histogram = createHistogram();
for (let i = 0; i < 1000; i++) {
    const start = performance.now();
    doWork();
    histogram.record(Math.round((performance.now() - start) * 1000)); // microseconds
}
console.log(`P50: ${histogram.percentile(50)}us, P99: ${histogram.percentile(99)}us`);
Use in production: Wrap critical operations (DB queries, external API calls, template rendering) with performance marks. Export histograms to Prometheus/Datadog for P50/P95/P99 monitoring.What interviewers are really testing: Do you use built-in performance tools or only Date.now()? Do you think in percentiles?Follow-up:
  • “Why is P99 latency more important than average latency? What does it tell you?”
  • “How do you export performance metrics from Node to a monitoring system?”
Answer:
const v8 = require('v8');

// Take snapshot programmatically
const snapshotPath = v8.writeHeapSnapshot();
console.log(`Snapshot written to ${snapshotPath}`);

// Trigger snapshot on demand via signal (production-safe)
process.on('SIGUSR2', () => {
    const path = v8.writeHeapSnapshot();
    console.log(`Heap snapshot: ${path}`);
});
// Then: kill -SIGUSR2 <pid>
Analyzing snapshots in Chrome DevTools:
  1. Open chrome://inspect -> “Open dedicated DevTools for Node”
  2. Memory tab -> Load snapshot
  3. Key views:
    • Summary: Objects grouped by constructor. Look for unexpectedly large counts.
    • Comparison: Load two snapshots, see what grew between them. This is the most powerful memory leak detection tool.
    • Containment: Shows the retention tree — who is keeping an object alive.
    • Statistics: Pie chart of memory by type.
The “three snapshot technique” for finding leaks:
  1. Take snapshot after warmup (baseline).
  2. Perform the suspected leaky operation 5-10 times.
  3. Force GC (if --expose-gc is enabled) and take snapshot.
  4. Compare snapshots 1 and 3 — objects that grew are leak candidates.
What interviewers are really testing: Have you actually used heap snapshots to find a real memory leak? Can you describe the workflow?Follow-up:
  • “You have a production Node service that’s leaking memory. How do you take a heap snapshot safely without impacting users?”
  • “What is ‘retained size’ vs ‘shallow size’ in a heap snapshot?”
Answer:
# V8 built-in profiler (tick-based sampling)
node --prof app.js
# Run your workload...
# Generates isolate-0x*.log

# Process the profile into human-readable format
node --prof-process isolate-0x*.log > profile.txt
# Look for [JavaScript] section -- functions sorted by "ticks" (time spent)

# Chrome DevTools profiler (interactive, more powerful)
node --inspect app.js
# chrome://inspect -> Profiler -> Start -> Run workload -> Stop
# Shows: flame chart, heavy (bottom-up), tree (top-down), function-by-function

# Clinic.js flame (best DX for flame graphs)
npx clinic flame -- node app.js
# Auto-opens interactive flame graph in browser
Reading a flame graph:
  • X-axis: Not time! It’s sorted alphabetically. Width = percentage of total CPU time.
  • Y-axis: Call stack depth. Bottom = entry point, top = leaf functions.
  • Hot path: Follow the widest bars from bottom to top — that’s where most CPU time is spent.
  • Plateau: A wide bar at the top = a single function consuming lots of CPU (optimization target).
Common findings:
  • Wide JSON.parse bars: Parse large JSON on a worker thread.
  • Wide regex bars: Optimize or replace the regex.
  • Wide GC bars: Memory pressure — reduce allocations.
  • Wide _read or _write bars in streams: Stream processing bottleneck.
What interviewers are really testing: Can you interpret profiling output? Have you used these tools on a real performance issue?Follow-up:
  • “How does V8’s sampling profiler work? What’s the difference between sampling and instrumented profiling?”
  • “You see a flame graph where 40% of CPU time is in JSON.stringify. What do you do?”
Answer:
const http2 = require('http2');
const fs = require('fs');

const server = http2.createSecureServer({
    key: fs.readFileSync('server.key'),
    cert: fs.readFileSync('server.crt'),
    allowHTTP1: true  // Fallback for clients that don't support HTTP/2
});

server.on('stream', (stream, headers) => {
    const path = headers[':path'];
    
    // Server Push: proactively send resources the client will need
    if (path === '/index.html') {
        stream.pushStream({ ':path': '/style.css' }, (err, pushStream) => {
            if (!err) {
                pushStream.respond({ ':status': 200, 'content-type': 'text/css' });
                pushStream.end(fs.readFileSync('style.css'));
            }
        });
    }
    
    stream.respond({
        ':status': 200,
        'content-type': 'text/html'
    });
    stream.end('<h1>Hello HTTP/2</h1>');
});

server.listen(8443);
HTTP/2 advantages over HTTP/1.1:
  • Multiplexing: Multiple requests/responses over a single TCP connection (no head-of-line blocking at the HTTP level).
  • Header compression (HPACK): Reduces overhead for repeated headers.
  • Server Push: Proactively send resources before the client requests them.
  • Binary framing: More efficient than text-based HTTP/1.1.
In practice: Most Node apps don’t implement HTTP/2 directly. Instead, a reverse proxy (Nginx, Cloudflare) handles HTTP/2 with clients and uses HTTP/1.1 to communicate with Node. This is simpler and Nginx handles HTTP/2 more efficiently.What interviewers are really testing: Do you know HTTP/2’s multiplexing benefit? Do you understand the typical deployment pattern with a reverse proxy?Follow-up:
  • “What is HTTP/2 server push and why has it been controversial? Why did Chrome remove push support?”
  • “What is head-of-line blocking and how does HTTP/2 partially solve it? What about HTTP/3?”
Answer:
const { WebSocketServer } = require('ws');
const http = require('http');

const server = http.createServer();
const wss = new WebSocketServer({ server });

// Connection tracking
const clients = new Map();

wss.on('connection', (ws, req) => {
    const userId = authenticateFromHeaders(req.headers);
    clients.set(userId, ws);
    
    ws.isAlive = true;
    ws.on('pong', () => { ws.isAlive = true; });
    
    ws.on('message', (data) => {
        const message = JSON.parse(data);
        switch (message.type) {
            case 'chat':
                broadcast(message, userId);
                break;
            case 'ping':
                ws.send(JSON.stringify({ type: 'pong' }));
                break;
        }
    });
    
    ws.on('close', () => {
        clients.delete(userId);
    });
    
    ws.on('error', (err) => {
        console.error('WebSocket error:', err);
        clients.delete(userId);
    });
});

// Heartbeat: detect dead connections
const heartbeat = setInterval(() => {
    wss.clients.forEach((ws) => {
        if (!ws.isAlive) return ws.terminate();
        ws.isAlive = false;
        ws.ping();
    });
}, 30000);

wss.on('close', () => clearInterval(heartbeat));

function broadcast(message, senderId) {
    const data = JSON.stringify({ ...message, from: senderId });
    for (const [id, client] of clients) {
        if (id !== senderId && client.readyState === 1) { // 1 = OPEN
            client.send(data);
        }
    }
}

server.listen(8080);
Production concerns: Heartbeat for dead connection detection, authentication on upgrade, message size limits, rate limiting per connection, and scaling across multiple servers via Redis pub/sub.What interviewers are really testing: Do you implement heartbeat? Do you handle connection cleanup?Follow-up:
  • “How do you authenticate a WebSocket connection? Can you use cookies?”
  • “What happens if a client sends messages faster than the server can process them? How do you handle backpressure on WebSocket?”
Answer:
const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');

// Type definitions
const typeDefs = `#graphql
    type User {
        id: ID!
        name: String!
        email: String!
        posts: [Post!]!
    }
    
    type Post {
        id: ID!
        title: String!
        author: User!
    }
    
    type Query {
        user(id: ID!): User
        users(limit: Int, offset: Int): [User!]!
    }
    
    type Mutation {
        createUser(name: String!, email: String!): User!
    }
`;

// Resolvers with DataLoader for N+1 prevention
const resolvers = {
    Query: {
        user: (_, { id }, { dataSources }) => dataSources.users.findById(id),
        users: (_, { limit = 20, offset = 0 }, { dataSources }) =>
            dataSources.users.findAll({ limit, offset }),
    },
    User: {
        // DataLoader batches these calls automatically
        posts: (user, _, { loaders }) => loaders.postsByUser.load(user.id),
    },
    Mutation: {
        createUser: (_, args, { dataSources }) => dataSources.users.create(args),
    }
};

const server = new ApolloServer({ typeDefs, resolvers });
await server.start();

app.use('/graphql', expressMiddleware(server, {
    context: async ({ req }) => ({
        user: await authenticate(req),
        dataSources: { users: new UserDataSource() },
        loaders: {
            postsByUser: new DataLoader(batchPostsByUserIds)
        }
    })
}));
What interviewers are really testing: Do you use DataLoader to solve N+1? Do you create per-request context?Follow-up:
  • “Why is a new DataLoader created for each request instead of sharing one globally?”
  • “What is query complexity analysis and how do you prevent abusive queries (deeply nested, huge)?”
Answer:
const amqp = require('amqplib');

class MessageQueue {
    async connect() {
        this.connection = await amqp.connect(process.env.RABBITMQ_URL);
        this.channel = await this.connection.createChannel();
        
        // Prefetch: only process 10 messages at a time per consumer
        await this.channel.prefetch(10);
        
        // Handle connection errors
        this.connection.on('error', (err) => {
            console.error('RabbitMQ connection error:', err);
            setTimeout(() => this.connect(), 5000); // Reconnect
        });
    }
    
    async publish(queue, message, options = {}) {
        await this.channel.assertQueue(queue, {
            durable: true,               // Survives broker restart
            deadLetterExchange: 'dlx',   // Failed messages go here
        });
        
        this.channel.sendToQueue(queue, Buffer.from(JSON.stringify(message)), {
            persistent: true,            // Message survives broker restart
            ...options
        });
    }
    
    async consume(queue, handler) {
        await this.channel.assertQueue(queue, { durable: true });
        
        this.channel.consume(queue, async (msg) => {
            if (!msg) return;
            
            try {
                const data = JSON.parse(msg.content.toString());
                await handler(data);
                this.channel.ack(msg);   // Acknowledge: remove from queue
            } catch (err) {
                console.error('Processing failed:', err);
                // Reject and requeue (or send to dead letter queue)
                this.channel.nack(msg, false, false); // Don't requeue, send to DLX
            }
        });
    }
}

// Usage
const mq = new MessageQueue();
await mq.connect();

// Producer
await mq.publish('email-tasks', { to: 'user@example.com', template: 'welcome' });

// Consumer (separate process)
await mq.consume('email-tasks', async (task) => {
    await sendEmail(task.to, task.template);
});
Key concepts: Durable queues, persistent messages, prefetch (concurrency control), acknowledgments, dead letter queues (DLQ) for failed messages.What interviewers are really testing: Do you handle message acknowledgment? Do you know about dead letter queues? Do you set prefetch limits?Follow-up:
  • “What happens if a consumer crashes while processing a message but before acknowledging it?”
  • “What is a dead letter queue and when would you use it?”
  • “How does RabbitMQ’s prefetch relate to consumer concurrency?”
Answer:
const Redis = require('ioredis');
const client = new Redis(process.env.REDIS_URL);

// 1. Cache-aside with stampede protection
async function getCachedData(key, fetchFn, ttlSeconds = 3600) {
    const cached = await client.get(key);
    if (cached) return JSON.parse(cached);
    
    // Stampede protection: use a lock so only one process fetches
    const lockKey = `lock:${key}`;
    const acquired = await client.set(lockKey, '1', 'EX', 10, 'NX'); // NX = only if not exists
    
    if (acquired) {
        try {
            const data = await fetchFn();
            await client.set(key, JSON.stringify(data), 'EX', ttlSeconds);
            return data;
        } finally {
            await client.del(lockKey);
        }
    } else {
        // Another process is fetching -- wait and retry
        await new Promise(resolve => setTimeout(resolve, 100));
        return getCachedData(key, fetchFn, ttlSeconds); // Retry
    }
}

// 2. Pub/Sub for cache invalidation across instances
const subscriber = new Redis(process.env.REDIS_URL);
subscriber.subscribe('cache-invalidation');
subscriber.on('message', (channel, message) => {
    const { key } = JSON.parse(message);
    localCache.delete(key); // Invalidate local in-memory cache
});

// When updating data:
async function updateUser(id, data) {
    await db.users.update(id, data);
    await client.del(`user:${id}`);
    // Notify all instances to clear their local cache
    await client.publish('cache-invalidation', JSON.stringify({ key: `user:${id}` }));
}

// 3. Rate limiting with sliding window (Lua for atomicity)
const slidingWindowRateLimit = `
    local key = KEYS[1]
    local now = tonumber(ARGV[1])
    local window = tonumber(ARGV[2])
    local limit = tonumber(ARGV[3])
    
    redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
    local count = redis.call('ZCARD', key)
    
    if count < limit then
        redis.call('ZADD', key, now, now .. math.random())
        redis.call('EXPIRE', key, window / 1000)
        return 1
    end
    return 0
`;
What interviewers are really testing: Do you know about cache stampede prevention? Can you use Redis beyond simple get/set?Follow-up:
  • “What is a cache stampede and how does your locking pattern prevent it?”
  • “Why use a Lua script for rate limiting instead of multiple Redis commands?”
  • “How does Redis Cluster differ from Redis Sentinel? When do you need each?”
Answer: Graceful shutdown ensures in-flight requests complete before the process exits. Without it, users get broken responses and database transactions get left uncommitted.
const server = app.listen(3000);
let isShuttingDown = false;

// Reject new requests during shutdown
app.use((req, res, next) => {
    if (isShuttingDown) {
        res.set('Connection', 'close');
        return res.status(503).json({ error: 'Server is shutting down' });
    }
    next();
});

async function gracefulShutdown(signal) {
    console.log(`${signal} received. Starting graceful shutdown...`);
    isShuttingDown = true;
    
    // 1. Stop accepting new connections
    server.close(async () => {
        console.log('HTTP server closed');
        
        try {
            // 2. Close database connections
            await mongoose.connection.close();
            console.log('Database connections closed');
            
            // 3. Close Redis connections
            await redisClient.quit();
            console.log('Redis connection closed');
            
            // 4. Close message queue connections
            await rabbitMQConnection.close();
            console.log('RabbitMQ connection closed');
            
            console.log('Graceful shutdown complete');
            process.exit(0);
        } catch (err) {
            console.error('Error during shutdown:', err);
            process.exit(1);
        }
    });
    
    // Force shutdown after timeout (don't hang forever)
    setTimeout(() => {
        console.error('Forced shutdown after timeout');
        process.exit(1);
    }, 30000); // 30 second timeout
}

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
Why this matters in Kubernetes: When a pod is terminated, Kubernetes sends SIGTERM, then waits terminationGracePeriodSeconds (default 30s), then sends SIGKILL. Your app must handle SIGTERM to drain connections within that window.What interviewers are really testing: Do you drain connections? Do you close resources in the right order? Do you have a force-kill timeout?Red flag answer: “I just let the process die, it restarts quickly.” This drops in-flight requests and can corrupt data.Follow-up:
  • “In what order should you close resources during shutdown? Why?”
  • “What happens to WebSocket connections during graceful shutdown?”
  • “How does Kubernetes’s pod termination lifecycle interact with your graceful shutdown?”
Answer:
// Liveness check: "Is the process alive and not deadlocked?"
app.get('/health/live', (req, res) => {
    res.status(200).json({ status: 'ok' });
});

// Readiness check: "Can this instance serve traffic?"
app.get('/health/ready', async (req, res) => {
    const checks = {};
    let healthy = true;
    
    // Check database
    try {
        await pool.query('SELECT 1');
        checks.database = 'ok';
    } catch (err) {
        checks.database = 'failed';
        healthy = false;
    }
    
    // Check Redis
    try {
        await redis.ping();
        checks.redis = 'ok';
    } catch (err) {
        checks.redis = 'failed';
        healthy = false;
    }
    
    // Check memory
    const memUsage = process.memoryUsage();
    const heapPercent = memUsage.heapUsed / memUsage.heapTotal;
    checks.memory = {
        heapUsedMB: Math.round(memUsage.heapUsed / 1024 / 1024),
        heapTotalMB: Math.round(memUsage.heapTotal / 1024 / 1024),
        heapPercent: Math.round(heapPercent * 100),
    };
    if (heapPercent > 0.95) healthy = false;
    
    // Check event loop lag
    checks.uptime = Math.round(process.uptime());
    
    res.status(healthy ? 200 : 503).json({
        status: healthy ? 'healthy' : 'degraded',
        checks,
        timestamp: new Date().toISOString()
    });
});
Liveness vs Readiness (Kubernetes terminology):
  • Liveness: “Is the process stuck?” Failure -> Kubernetes kills and restarts the pod.
  • Readiness: “Can it handle traffic?” Failure -> Kubernetes removes the pod from the Service load balancer (no traffic routed) but doesn’t kill it. Pod can recover and become ready again.
Anti-pattern: Making the liveness check depend on external services (database, Redis). If the database is down, killing and restarting your pod won’t fix it — now you have a restart loop on top of a database outage.What interviewers are really testing: Do you separate liveness from readiness? Do you avoid the “cascading restart” anti-pattern?Follow-up:
  • “Why should the liveness check NOT depend on the database? What happens if it does?”
  • “What is a startup probe in Kubernetes and when do you need it?”
Answer:
// 1. Server-side timeout (protect your server)
const server = app.listen(3000);
server.timeout = 30000;       // 30s overall timeout
server.keepAliveTimeout = 65000; // Must be > load balancer idle timeout (usually 60s)
server.headersTimeout = 66000;   // Must be > keepAliveTimeout

// 2. Route-level timeout middleware
function timeout(ms) {
    return (req, res, next) => {
        const timer = setTimeout(() => {
            if (!res.headersSent) {
                res.status(504).json({ error: 'Request timeout' });
            }
        }, ms);
        
        res.on('finish', () => clearTimeout(timer));
        next();
    };
}

app.use('/api/reports', timeout(60000));  // 60s for heavy reports
app.use('/api', timeout(10000));          // 10s default

// 3. Outgoing request timeout (protect from slow dependencies)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);

try {
    const response = await fetch('http://slow-service/api', {
        signal: controller.signal
    });
    clearTimeout(timeoutId);
} catch (err) {
    if (err.name === 'AbortError') {
        console.error('Request to slow-service timed out');
    }
    throw err;
}
The keepAliveTimeout gotcha: If your load balancer (ALB, Nginx) has a 60s idle timeout but Node’s keepAliveTimeout is the default 5s, Node closes the connection before the load balancer expects it, causing 502 errors. Always set keepAliveTimeout > load_balancer_timeout.What interviewers are really testing: Do you set timeouts on both incoming and outgoing requests? Do you know about the keepAliveTimeout issue?Follow-up:
  • “What is the difference between server.timeout and server.keepAliveTimeout?”
  • “How do you handle a timeout when a database query is already in progress? Does the query get cancelled?”
Answer:
// 1. Continuous monitoring: track heap over time
const memoryMetrics = setInterval(() => {
    const { heapUsed, heapTotal, rss, external } = process.memoryUsage();
    metrics.gauge('node.heap.used', heapUsed);
    metrics.gauge('node.heap.total', heapTotal);
    metrics.gauge('node.rss', rss);
    metrics.gauge('node.external', external);
    metrics.gauge('node.active_handles', process._getActiveHandles().length);
    metrics.gauge('node.active_requests', process._getActiveRequests().length);
}, 10000).unref();

// 2. On-demand heap snapshots (production-safe)
process.on('SIGUSR2', () => {
    const v8 = require('v8');
    const path = v8.writeHeapSnapshot();
    console.log(`Heap snapshot written to ${path}`);
    // WARNING: Takes ~1-2 seconds per GB of heap. Causes a pause!
});

// 3. Automated leak detection (alert on monotonic growth)
// If heapUsed grows by > 50MB over 1 hour without dropping, alert.
The three-snapshot workflow:
  1. Take snapshot at baseline (after warmup).
  2. Reproduce the suspected leak (run load test for 5 minutes).
  3. Take snapshot after forced GC.
  4. Compare: sort by “Objects allocated between Snapshot 1 and 2.” Look for unexpected growth in specific constructors.
Common findings:
  • (closure) growing: Event listeners or closures holding references.
  • (string) growing: Unbounded string concatenation or logging.
  • (array) growing: Cache without eviction.
  • Specific class names growing: Code-level leak in that class.
What interviewers are really testing: Do you have a systematic approach to leak detection, or do you just restart the process when memory gets high?Follow-up:
  • “Your production service’s heap grows from 200MB to 1.5GB over 6 hours, then OOMs. Walk me through your debugging approach.”
  • “How do you take a heap snapshot in production without impacting user requests?”
Answer: N-API (Node-API) provides a stable C ABI for building native addons that work across Node.js versions without recompilation.
// addon.cc -- C++ N-API addon
#include <napi.h>

// CPU-intensive function implemented in C++
Napi::Number FibonacciSync(const Napi::CallbackInfo& info) {
    Napi::Env env = info.Env();
    int n = info[0].As<Napi::Number>().Int32Value();
    
    long long a = 0, b = 1;
    for (int i = 0; i < n; i++) {
        long long temp = a;
        a = b;
        b = temp + b;
    }
    
    return Napi::Number::New(env, a);
}

// Async version (runs on Libuv thread pool, doesn't block event loop)
class FibonacciWorker : public Napi::AsyncWorker {
public:
    FibonacciWorker(Napi::Function& callback, int n)
        : Napi::AsyncWorker(callback), n(n), result(0) {}
    
    void Execute() override {
        // Runs on worker thread -- NO V8 access here!
        long long a = 0, b = 1;
        for (int i = 0; i < n; i++) {
            long long temp = a;
            a = b;
            b = temp + b;
        }
        result = a;
    }
    
    void OnOK() override {
        // Back on main thread -- can access V8
        Callback().Call({ Env().Null(), Napi::Number::New(Env(), result) });
    }
    
private:
    int n;
    long long result;
};

Napi::Object Init(Napi::Env env, Napi::Object exports) {
    exports.Set("fibSync", Napi::Function::New(env, FibonacciSync));
    // ... register async version too
    return exports;
}

NODE_API_MODULE(addon, Init)
When to write native addons:
  • Performance-critical code (100x+ speedup over JS for numerical work).
  • Interfacing with system libraries (libcurl, OpenCV, system calls).
  • Reusing existing C/C++ code.
Alternatives to C++: Use Rust via neon or napi-rs for memory-safe native addons. Increasingly popular in the Node ecosystem.What interviewers are really testing: Do you know when native addons are justified? Do you understand the async worker pattern?Follow-up:
  • “What is the difference between N-API and the older NAN (Native Abstractions for Node)?”
  • “Why can’t you access V8/JavaScript objects from within the Execute() method of an AsyncWorker?”
  • “When would you choose a Rust native addon over C++?”
Answer:
// Pattern 1: REST with circuit breaker
const CircuitBreaker = require('opossum');

const breaker = new CircuitBreaker(
    async (orderId) => {
        const res = await fetch(`http://inventory-service/api/check/${orderId}`, {
            signal: AbortSignal.timeout(3000) // 3s timeout
        });
        if (!res.ok) throw new Error(`Inventory check failed: ${res.status}`);
        return res.json();
    },
    {
        timeout: 5000,              // Consider request failed after 5s
        errorThresholdPercentage: 50, // Open circuit if 50% of requests fail
        resetTimeout: 30000,        // Try again after 30s
    }
);

breaker.on('open', () => console.warn('Circuit breaker OPEN: inventory service degraded'));
breaker.on('halfOpen', () => console.info('Circuit breaker HALF-OPEN: testing inventory service'));
breaker.on('close', () => console.info('Circuit breaker CLOSED: inventory service recovered'));

// Usage in route handler
app.post('/api/order', async (req, res) => {
    try {
        const inventory = await breaker.fire(req.body.orderId);
        // Process order...
    } catch (err) {
        if (err.message === 'Breaker is open') {
            return res.status(503).json({ error: 'Inventory service unavailable, try later' });
        }
        throw err;
    }
});

// Pattern 2: Event-driven with message queue (decoupled)
// Order service publishes event, doesn't wait for response
await messageQueue.publish('order.created', {
    orderId: order.id,
    items: order.items,
    userId: order.userId
});

// Inventory service subscribes and processes asynchronously
await messageQueue.consume('order.created', async (event) => {
    await reserveInventory(event.items);
    await messageQueue.publish('inventory.reserved', { orderId: event.orderId });
});
The circuit breaker pattern prevents cascading failures: when a downstream service is failing, stop sending requests to it (open the circuit). This prevents your service from hanging on timeouts and using up all its connections.Service mesh (Istio, Linkerd): In production Kubernetes environments, these concerns (circuit breaking, retries, timeouts, mTLS) are often handled at the infrastructure layer via a sidecar proxy, not in application code.What interviewers are really testing: Do you know about circuit breakers? Can you choose between synchronous and event-driven communication?Follow-up:
  • “What are the three states of a circuit breaker? How does the half-open state work?”
  • “When would you use an API gateway vs direct service-to-service communication?”
  • “How does a service mesh differ from implementing resilience patterns in application code?“

8. Gap-Filling Questions (Critical Topics)

Answer: AsyncLocalStorage (from async_hooks module) provides a way to store context that is automatically propagated through the entire async call chain of a request — without passing it explicitly through every function parameter.The problem it solves: In a microservice handling concurrent requests, you need to associate logs, metrics, and database queries with the specific request that triggered them. Without AsyncLocalStorage, you’d have to pass a requestId through every function call.
const { AsyncLocalStorage } = require('async_hooks');
const crypto = require('crypto');

const requestContext = new AsyncLocalStorage();

// Middleware: create context for each request
app.use((req, res, next) => {
    const context = {
        requestId: req.headers['x-request-id'] || crypto.randomUUID(),
        userId: null, // Set after auth
        startTime: Date.now(),
    };
    
    requestContext.run(context, () => next());
});

// Anywhere in your code -- no parameter passing needed!
function getRequestId() {
    return requestContext.getStore()?.requestId || 'no-context';
}

// Logger automatically includes request context
const logger = {
    info(msg, data = {}) {
        const ctx = requestContext.getStore() || {};
        console.log(JSON.stringify({
            level: 'info',
            requestId: ctx.requestId,
            userId: ctx.userId,
            msg,
            ...data,
            timestamp: new Date().toISOString()
        }));
    }
};

// Deep in your service layer -- context is available
async function processOrder(orderData) {
    logger.info('Processing order', { orderId: orderData.id });
    // Log output includes requestId automatically!
    
    const result = await db.query('INSERT INTO orders ...');
    logger.info('Order saved', { orderId: orderData.id });
    return result;
}
How it works internally: AsyncLocalStorage hooks into Node’s async resource tracking (async_hooks). When an async operation (Promise, setTimeout, I/O callback) is created within a .run() context, the context is automatically associated with that async operation and all its descendants.Performance: In Node 16+, AsyncLocalStorage has minimal overhead (~2-5% in benchmarks). Earlier versions had higher overhead due to the async_hooks implementation.What interviewers are really testing: Do you know how to propagate request context without prop-drilling? This is essential for observability in production services.Red flag answer: “I pass requestId as a parameter to every function.” This works but doesn’t scale and makes function signatures noisy.Follow-up:
  • “How does AsyncLocalStorage differ from thread-local storage in Java? What’s the Node equivalent?”
  • “What are async_hooks and what performance concerns do they have?”
  • “How would you use AsyncLocalStorage to implement distributed tracing across microservices?”
Answer: Supply chain attacks exploit the npm ecosystem — compromising a package that thousands of projects depend on. This is one of the most dangerous attack vectors for Node.js applications.Notable incidents:
  • event-stream (2018): Malicious code injected into a popular package (2M weekly downloads) that targeted a specific Bitcoin wallet. The attacker gained maintainer access through social engineering.
  • ua-parser-js (2021): Compromised package ran cryptominers on developer machines. 8M weekly downloads affected.
  • colors/faker (2022): Maintainer intentionally sabotaged their own packages in protest, breaking thousands of projects.
Defense layers:
  1. Lock files (package-lock.json / yarn.lock): Ensures reproducible installs. Always commit lock files.
  2. npm audit / Snyk / Socket.dev: Scan dependencies for known vulnerabilities.
npm audit                     # Check for known vulnerabilities
npm audit fix                 # Auto-fix compatible updates
npx socket scan ./            # Detect supply chain risks (new)
  1. Lockfile-only installs in CI:
npm ci                        # Clean install from lockfile only (no modifications)
# NOT `npm install` which can modify the lockfile
  1. Pin exact versions (debatable but safer):
{
    "dependencies": {
        "express": "4.18.2"   
    }
}
  1. Review new dependencies:
    • Check npm download counts, GitHub stars, maintainer history.
    • Use npm pack to inspect what’s actually published (can differ from GitHub source).
    • Check for install scripts: "preinstall": "node malicious.js" is a red flag.
  2. Disable install scripts for untrusted packages:
npm install --ignore-scripts
# Or in .npmrc: ignore-scripts=true
  1. Use a private registry (Artifactory, Verdaccio) that proxies npm and caches approved packages.
What interviewers are really testing: Are you aware of supply chain risks specific to npm? Do you have a strategy beyond npm audit?Red flag answer: “I just run npm install and trust the packages.” This is how supply chain attacks succeed.Follow-up:
  • “How would you detect if a dependency update introduces malicious code?”
  • “What is the difference between npm install and npm ci? Why does it matter in CI/CD?”
  • “A critical vulnerability is found in a transitive dependency 4 levels deep. How do you fix it?”
Answer: Understanding how Node.js processes interact with the OS and container orchestrators is critical for production reliability.Process signals:
  • SIGTERM (15): Polite “please terminate.” Sent by Kubernetes, Docker, PM2. Your app should handle this for graceful shutdown.
  • SIGINT (2): Interrupt from terminal (Ctrl+C). Handle like SIGTERM.
  • SIGKILL (9): Forced kill. Cannot be caught or handled. The process dies immediately. Kubernetes sends this after terminationGracePeriodSeconds.
  • SIGHUP (1): Terminal hangup. Some apps use it to reload config.
  • SIGUSR1 (10): Node uses this to activate the debugger. Don’t override.
  • SIGUSR2 (12): User-defined. Good for triggering heap snapshots.
The PID 1 problem in Docker:
# BAD: Node runs as PID 1, doesn't handle signals properly
CMD ["node", "server.js"]

# GOOD: Use tini as init system
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]

# Or use Docker's --init flag
# docker run --init my-app
When Node runs as PID 1, it doesn’t have default signal handlers for SIGTERM/SIGINT — signals are ignored unless you explicitly process.on('SIGTERM', ...). Tini acts as a proper init process that forwards signals correctly.Container lifecycle in Kubernetes:
Pod scheduled -> Init containers run -> Main containers start -> Readiness probe passes -> Traffic routed
                                                                                            |
Pod termination: SIGTERM sent -> terminationGracePeriodSeconds (default 30s) -> SIGKILL if still running
                                  |
                           Your graceful shutdown happens here
beforeExit vs exit events:
process.on('beforeExit', async (code) => {
    // Runs when event loop is empty (async work possible)
    await flushLogs();
});

process.on('exit', (code) => {
    // Runs synchronously just before process exits
    // NO async work here -- event loop is done
    console.log(`Process exiting with code ${code}`);
});
What interviewers are really testing: Do you know the PID 1 problem? Can you reason about the container termination lifecycle?Red flag answer: “I just deploy the Docker image and it works.” Missing signal handling means dropped requests and data loss on every deployment.Follow-up:
  • “What is the PID 1 problem in Docker and how does tini solve it?”
  • “Your Node app in Kubernetes restarts frequently with exit code 137. What does that mean?”
  • “How do you ensure zero-downtime deployments in Kubernetes with a Node.js app?”
Answer: AbortController provides a standard mechanism to cancel async operations — network requests, database queries, timers, or any long-running work.
// Basic usage: cancel a fetch request
const controller = new AbortController();
const { signal } = controller;

setTimeout(() => controller.abort(), 5000); // 5s timeout

try {
    const response = await fetch('https://slow-api.com/data', { signal });
    const data = await response.json();
} catch (err) {
    if (err.name === 'AbortError') {
        console.log('Request was cancelled (timeout)');
    } else {
        throw err; // Re-throw non-cancellation errors
    }
}

// Convenience: AbortSignal.timeout (Node 18+)
const response = await fetch(url, {
    signal: AbortSignal.timeout(5000) // Built-in timeout signal
});

// AbortSignal.any (Node 20+): cancel when ANY signal fires
const userCancel = new AbortController();
const timeoutSignal = AbortSignal.timeout(10000);

const response = await fetch(url, {
    signal: AbortSignal.any([userCancel.signal, timeoutSignal])
});

// Custom cancellable operations
async function processWithCancellation(data, signal) {
    for (const item of data) {
        if (signal?.aborted) {
            throw new DOMException('Operation cancelled', 'AbortError');
        }
        await processItem(item);
    }
}

// Cancel all operations for a request when client disconnects
app.get('/long-operation', async (req, res) => {
    const controller = new AbortController();
    
    req.on('close', () => controller.abort()); // Client disconnected
    
    try {
        const result = await longOperation(controller.signal);
        res.json(result);
    } catch (err) {
        if (err.name === 'AbortError') {
            // Client disconnected, no response needed
            return;
        }
        throw err;
    }
});
What interviewers are really testing: Do you handle client disconnection? Do you use AbortController for timeouts instead of manual setTimeout + cleanup?Red flag answer: Not knowing AbortController exists, or using manual timeout patterns with potential cleanup issues.Follow-up:
  • “How do you propagate cancellation through a chain of async operations (e.g., HTTP handler -> service -> database)?”
  • “What happens to a database query that’s already executing when you abort the signal?”
  • “How does AbortSignal.any() simplify complex cancellation scenarios?”
Answer: diagnostics_channel (Node 16+) is a publish/subscribe API for diagnostic data within a Node.js application. It provides a low-overhead way to instrument code without modifying it.
const diagnostics_channel = require('diagnostics_channel');

// Create a channel
const httpChannel = diagnostics_channel.channel('http.request');

// Publisher (in your HTTP handling code)
app.use((req, res, next) => {
    const start = process.hrtime.bigint();
    
    res.on('finish', () => {
        const duration = Number(process.hrtime.bigint() - start) / 1_000_000;
        httpChannel.publish({
            method: req.method,
            url: req.url,
            statusCode: res.statusCode,
            duration,
            contentLength: res.get('content-length'),
        });
    });
    
    next();
});

// Subscriber (in your monitoring/observability setup)
httpChannel.subscribe((message) => {
    metrics.histogram('http.request.duration', message.duration, {
        method: message.method,
        status: message.statusCode,
        path: message.url,
    });
    
    if (message.duration > 1000) {
        logger.warn({ ...message }, 'Slow request detected');
    }
});
Modern Node.js observability stack:
  1. Metrics: prom-client (Prometheus) or custom metrics -> Grafana dashboards.
  2. Tracing: OpenTelemetry SDK -> trace requests across services.
  3. Logging: Pino -> stdout -> log collector -> centralized search.
  4. Profiling: --inspect + Chrome DevTools for ad-hoc, clinic.js for automated.
OpenTelemetry integration (the emerging standard):
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');

const sdk = new NodeSDK({
    serviceName: 'user-api',
    traceExporter: new OTLPTraceExporter({ url: 'http://otel-collector:4318' }),
    instrumentations: [getNodeAutoInstrumentations()]
    // Auto-instruments: http, express, pg, redis, grpc, etc.
});

sdk.start();
// All HTTP requests, DB queries, Redis calls are now traced automatically
What interviewers are really testing: Do you think about observability as a first-class concern? Do you know OpenTelemetry? Can you set up distributed tracing?Red flag answer: “I check the logs when something goes wrong.” Reactive debugging without proactive observability means you only find problems after users report them.Follow-up:
  • “What is the difference between metrics, logs, and traces? When do you use each?”
  • “How does OpenTelemetry’s auto-instrumentation work? What does it instrument in a Node.js app?”
  • “What is context propagation in distributed tracing and why is it essential for microservices?”
Answer: In production, you need to actively monitor the event loop and tune the thread pool — these are the two most impactful performance levers.Event Loop Monitoring:
const { monitorEventLoopDelay } = require('perf_hooks');

// High-resolution event loop delay histogram
const histogram = monitorEventLoopDelay({ resolution: 20 }); // 20ms resolution
histogram.enable();

// Report every 10 seconds
setInterval(() => {
    console.log({
        min: histogram.min / 1e6,         // Convert nanoseconds to ms
        max: histogram.max / 1e6,
        mean: histogram.mean / 1e6,
        p50: histogram.percentile(50) / 1e6,
        p99: histogram.percentile(99) / 1e6,
        stddev: histogram.stddev / 1e6,
    });
    histogram.reset();
}, 10000).unref();

// Healthy: P99 < 20ms
// Warning: P99 20-100ms (something is blocking)
// Critical: P99 > 100ms (event loop is seriously blocked)
Libuv Thread Pool Tuning:
# Default: 4 threads
UV_THREADPOOL_SIZE=4

# For I/O-heavy apps (many concurrent file reads, DNS lookups, crypto):
UV_THREADPOOL_SIZE=16

# Max: 1024 (but more isn't always better -- each thread uses ~2MB stack)
What uses the thread pool:
  • fs.* operations (all file system calls)
  • dns.lookup() (NOT dns.resolve() which uses c-ares and the network directly)
  • crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes()
  • zlib compression
  • Custom C++ addons using uv_queue_work
What does NOT use the thread pool (uses OS kernel async directly):
  • TCP/UDP sockets (net, http, https)
  • dns.resolve(), dns.resolveAny() (uses c-ares library)
  • Pipes, signals, child process I/O
Sizing heuristic: If you’re seeing event loop lag and your workload is I/O-heavy (lots of file operations, crypto), try doubling UV_THREADPOOL_SIZE. Monitor the impact. Too many threads waste memory and cause context-switching overhead.What interviewers are really testing: Can you distinguish between what uses the thread pool and what uses kernel async? Can you monitor event loop health?Red flag answer: “I just set UV_THREADPOOL_SIZE=1024 to be safe.” Too many threads wastes memory and increases context switching.Follow-up:
  • “Why does dns.lookup() use the thread pool but dns.resolve() does not?”
  • “You notice your Node app’s P99 latency increases when you add more concurrent fs.readFile calls. Why might that happen?”
  • “How does monitorEventLoopDelay differ from the simple setInterval lag detection approach?”
Answer: Understanding Node.js release cycles is important for production decision-making and interview discussions about technical leadership.Release schedule:
  • Even-numbered releases (18, 20, 22): Become LTS (Long-Term Support). Get 30 months of support (12 months Active LTS + 18 months Maintenance LTS).
  • Odd-numbered releases (19, 21, 23): Current release. Only supported for 6 months. Never becomes LTS. Meant for testing new features before the next LTS.
Version manager: Use nvm (Unix) or fnm (cross-platform, faster) to manage multiple Node versions:
# .nvmrc or .node-version in project root
20.11.1

# Auto-switch on directory change (in .bashrc/.zshrc)
nvm use
Key features by version (interview-relevant):
  • Node 18 LTS: fetch API built-in, test runner (node:test), AbortSignal.timeout().
  • Node 20 LTS: Stable test runner, import.meta.resolve, permission model (--experimental-permission).
  • Node 22 LTS: require() for ESM modules (experimental), WebSocket client, glob and matchesGlob in fs.
Upgrade strategy for production:
  1. Run tests on new LTS version in CI before upgrading.
  2. Check for deprecated APIs (node --pending-deprecation app.js).
  3. Test native addon compatibility (C++ modules may need recompilation).
  4. Roll out to staging, monitor for 1-2 weeks, then production.
What interviewers are really testing: Do you make informed decisions about Node versions? Do you stay on LTS? Can you articulate what changed between major versions?Red flag answer: “I just use whatever version comes with the Docker image” or “I’m still on Node 14.” Running unsupported versions in production is a security risk.Follow-up:
  • “You’re starting a new project today. Which Node.js version do you choose and why?”
  • “What is the Node.js permission model (--experimental-permission) and what problem does it solve?”
  • “How do you handle Node.js upgrades in a monorepo with 20 services?”