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 Event Loop & Core Concepts

This guide provides a foundational understanding of how Node.js works, its architecture, and how it handles asynchronous operations.

1. Introduction to Node.js

Node.js is a JavaScript Runtime Environment that allows JavaScript to run outside the browser. It is built by wrapping the Google V8 engine and the Libuv library to enable server-side development. Core Principle:
Node.js is a SINGLE-THREADED environment While JavaScript execution happens on a single thread, Node.js offloads complex tasks to the operating system or its internal thread pool.
Primary Use Cases:
  • I/O Operations: Reading/Writing files, Database calls.
  • Real-time Applications: Chat apps, Game servers.
  • REST APIs: Fast, scalable web services.

2. Event Loop Architecture

The Event Loop is the “heart” of Node.js. It monitors for pending tasks and manages asynchronous operations, enabling non-blocking I/O.

How Node.js Reads Code

  1. Top-to-bottom: Executes synchronous code immediately.
  2. Delegation: Asynchronous tasks (like timers or network calls) are sent to the Event Loop.
  3. Callback Queue: Once a task is done, its callback is queued and processed when the main thread is free.

3. Blocking vs Non-Blocking

Blocking: The thread is “locked” until the operation completes. No other code can run.
const fs = require('fs');

// BLOCKING - Thread waits for file to be read
const data = fs.readFileSync('test.txt', 'utf-8');
console.log(data);
console.log('This waits for file read to complete');
Non-Blocking: The operation is delegated. The thread continues working on other things.
const fs = require('fs');

// NON-BLOCKING - Thread continues execution
fs.readFile('test.txt', 'utf-8', (err, data) => {
  if (err) throw err;
  console.log(data); // Executes when ready
});
console.log('This executes immediately');

4. Event Loop Phases & Priority

The Event Loop executes tasks in a specific hierarchical order:
PhasePriorityTasks
Microtask QueueHighestprocess.nextTick(), Promise.resolve()
Timers1st MacrotasksetTimeout(), setInterval()
I/O Callbacks2nd MacrotaskNetwork / File System results
Poll3rd MacrotaskNew I/O events
Check4th MacrotasksetImmediate()
Close5th MacrotaskSocket closures, cleanup
Output Prediction Example:
console.log('Start');
setTimeout(() => console.log('setTimeout'), 0);
Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('nextTick'));
console.log('End');

// Output: Start -> End -> nextTick -> Promise -> setTimeout

5. CPU Intensive Tasks & Multi-Threading

Because of its single-threaded nature, CPU-intensive tasks (like heavy loops) can block the entire server, making it unresponsive to other requests.

Solution 1: Child Processes (fork)

Creates a separate instance of Node.js with its own memory. Good for isolated scripts.

Solution 2: Worker Threads

Threads that share memory with the main process. More lightweight and efficient for mathematical calculations.

Thread Pool

Node.js uses Libuv to maintain a thread pool (default size: 4) for operations like file I/O, hash generation (crypto), and compression (zlib).

6. Streams & Buffers

Processing large data efficiently.
FeatureStreamBuffer
MechanismProcesses data in chunksLoads everything into memory
MemoryEfficient (low footprint)High (can crash on large files)
Use CaseVideo, big logs, file uploadsSmall, fixed-size data
Example (Piping Streams):
const fs = require('fs');
const readStream = fs.createReadStream('source.mp4');
const writeStream = fs.createWriteStream('dest.mp4');

// Transfers data chunk by chunk automatically
readStream.pipe(writeStream);

7. Rate Limiting

Crucial for protecting your server from DDoS attacks and Resource Exhaustion. Implementation: Use libraries like express-rate-limit to restrict requests per IP address.
const rateLimit = require('express-rate-limit');

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 mins
  max: 100 // Limit each IP to 100 requests
});

8. Event-Driven Architecture

Using the EventEmitter to create decoupled, scalable applications.
const EventEmitter = require('events');
const emitter = new EventEmitter();

// 1. Register listener
emitter.on('paymentSuccess', (data) => {
  console.log(`Sending email to ${data.email}...`);
});

// 2. Emit event
emitter.emit('paymentSuccess', { email: 'user@example.com' });

9. Common Interview Questions

Answer:The way I think about this is: process.nextTick() is the “cut the line” pass, while setImmediate() waits its turn in the queue.
  • process.nextTick() is a Microtask. It fires immediately after the currently executing operation completes, before the event loop moves to any other phase. Internally, Node maintains a separate nextTickQueue that gets drained completely between every phase transition of the event loop. This means 1,000 recursive nextTick calls will all execute before a single timer or I/O callback fires.
  • setImmediate() is a Macrotask that runs in the Check phase of the event loop. It executes after the Poll phase, which means I/O callbacks that are already queued will fire before your setImmediate callback.
  • The starvation problem is real: If you recursively call process.nextTick(), you starve the event loop. I/O callbacks, timers, and everything else will never fire. This is not theoretical — a team at one company discovered their WebSocket heartbeats were timing out because a data-transformation pipeline was chaining nextTick recursively. Switching to setImmediate fixed 100% of their connection drop issues overnight.
  • When to use which: Use nextTick when you need something to happen before any I/O (e.g., ensuring an event handler is registered before an event fires). Use setImmediate when you want to yield to the event loop and let pending I/O drain first.
// Starvation example -- DO NOT do this in production
function dangerous() {
  process.nextTick(dangerous); // Event loop never advances
}

// Safe alternative -- yields to I/O between calls
function safe() {
  setImmediate(safe); // I/O callbacks can fire between iterations
}
What interviewers are really testing: Whether you understand the event loop at the phase level, not just “async stuff.” They want to see if you can reason about execution ordering and identify starvation bugs.Red flag answer: “They’re basically the same thing, both are like setTimeout with 0.” This tells the interviewer you have never debugged a timing issue in Node.Follow-up:
  1. What happens if you call process.nextTick() inside a Promise .then() handler? Which executes first — the next nextTick or the next chained Promise?
  2. Can you describe a production scenario where choosing setImmediate over nextTick would prevent a bug?
  3. How does queueMicrotask() relate to process.nextTick() and which takes priority?
Answer:Node.js is single-threaded for JavaScript execution, but under the hood, Libuv maintains a thread pool for operations that cannot be handled asynchronously by the OS kernel alone.
  • Default size is 4 threads. You can increase it by setting UV_THREADPOOL_SIZE (max 1024). In production, teams running crypto-heavy workloads (like bcrypt password hashing) often bump this to 8-16. The setting must be applied before the thread pool initializes — set it at the very top of your entry file or via environment variable, not inside a route handler.
  • Operations that use the thread pool: File System operations (fs.readFile, fs.writeFile), cryptographic functions (crypto.pbkdf2, crypto.randomBytes), DNS lookups via dns.lookup() (but NOT dns.resolve() — this is a classic gotcha), and compression (zlib.gzip, zlib.deflate).
  • Operations that do NOT use the thread pool: Network I/O (TCP/UDP sockets, HTTP requests). These use OS-level async primitives (epoll on Linux, kqueue on macOS, IOCP on Windows) which are far more scalable.
  • Real-world impact: If you have 4 thread pool threads and 4 concurrent fs.readFile calls, a 5th call will queue and wait. A company discovered their API latency spiked 3x during peak hours because bcrypt hashing (which uses the thread pool) was competing with file uploads for the same 4 threads. Increasing UV_THREADPOOL_SIZE to 12 and moving file uploads to streaming (which bypasses the pool) brought p99 latency from 800ms back to 200ms.
// Set BEFORE any other code or imports
process.env.UV_THREADPOOL_SIZE = '8';

const crypto = require('crypto');
const fs = require('fs');

// Both compete for thread pool threads
crypto.pbkdf2('password', 'salt', 100000, 64, 'sha512', () => {});
fs.readFile('large-file.csv', () => {});
What interviewers are really testing: Do you understand the boundary between what Node handles on the main thread vs. what gets offloaded? Can you diagnose performance issues caused by thread pool exhaustion?Red flag answer: “Node.js doesn’t use threads at all, it’s completely single-threaded.” This is the most common misconception. It tells the interviewer you only know the marketing tagline, not the architecture.Follow-up:
  1. How would you diagnose thread pool exhaustion in production? What metrics or tools would you use?
  2. Why does dns.lookup() use the thread pool but dns.resolve() does not? What practical difference does this make?
  3. If you set UV_THREADPOOL_SIZE to 128 on a 4-core machine, what happens? Is bigger always better?
Answer:Both are methods on the child_process module, but they serve fundamentally different purposes.
  • fork() is specialized for creating child Node.js processes. It automatically sets up an IPC (Inter-Process Communication) channel between parent and child, so you can send messages via child.send() and process.on('message'). Each forked process gets its own V8 instance and its own memory space (typically 30-50 MB baseline). Use it when you need to offload CPU-heavy JavaScript work (like report generation or data transformation) to keep your main server responsive.
  • spawn() is general-purpose. It launches any executable as a child process (Python scripts, shell commands, ffmpeg, ImageMagick). Communication happens through stdio streams (stdout, stderr), not IPC. It does NOT create a new V8 instance, so memory overhead depends entirely on the spawned process.
  • Key difference in data handling: spawn streams data back via stdout, making it suitable for large outputs (e.g., piping a 2 GB database dump). fork sends structured JS objects over IPC, which must be serialized/deserialized — sending a 500 MB JSON object over IPC will serialize the entire thing and can cause out-of-memory errors.
  • exec() vs spawn(): exec buffers the entire output in memory (default 1 MB limit, configurable via maxBuffer) then passes it to the callback. spawn streams it. For a command that outputs 10 bytes, use exec. For a command that outputs 10 GB, use spawn.
// fork -- Node.js child with IPC
const { fork } = require('child_process');
const child = fork('./heavy-computation.js');
child.send({ data: largeDataset });
child.on('message', (result) => {
  console.log('Computation result:', result);
});

// spawn -- any system command, streamed output
const { spawn } = require('child_process');
const ffmpeg = spawn('ffmpeg', ['-i', 'input.mp4', '-codec', 'libx264', 'output.mp4']);
ffmpeg.stderr.on('data', (data) => {
  console.log('Progress:', data.toString());
});
What interviewers are really testing: Whether you understand process isolation, memory implications, and when to use IPC vs. streams. Senior candidates should mention the memory cost of forking and when Worker Threads are a better choice.Red flag answer: “fork is for Node, spawn is for other stuff.” This is technically correct but shows zero depth. The interviewer wants to hear about IPC channels, memory overhead, and real use-case reasoning.Follow-up:
  1. When would you choose Worker Threads over fork()? What is the key architectural difference?
  2. You need to run 50 concurrent fork() processes for a batch job. What happens to your server? How do you manage it?
  3. How does execFile() differ from exec(), and why does it matter for security?
Answer:The core issue is that Node.js runs your JavaScript on a single thread via the event loop. If any single operation hogs that thread (a tight loop, image processing, complex mathematical computation), every other request in the system is frozen. Your HTTP server stops accepting connections, WebSocket heartbeats fail, health checks timeout, and your load balancer marks the instance as dead.
  • Quantifying “heavy”: A computation that takes more than 50-100ms on the main thread is already dangerous in a production server handling concurrent requests. At 200ms of blocking, you will see noticeable latency spikes. At 1+ second, your server is effectively unresponsive during that window.
  • Mitigation strategies (in order of preference):
    1. Worker Threads (Node 12+): Share memory with the main process via SharedArrayBuffer, minimal startup overhead (~5ms), ideal for CPU-bound JS work like JSON parsing of massive payloads, bcrypt hashing, or data aggregation. Use a worker pool (like the piscina or workerpool libraries) to avoid the cost of spinning up new threads per request.
    2. Child Processes (fork/spawn): Full process isolation, own V8 heap. Higher memory cost (~30-50 MB each). Use when you need crash isolation (a segfault in the child does not kill the parent).
    3. Offload to specialized services: For image/video processing, use a dedicated service (Sharp + a queue, or a Python microservice). For ML inference, use a Python/Go service behind gRPC. A company I know moved PDF generation from their Node API to a separate Go microservice and reduced their p95 latency from 4.2s to 180ms.
    4. Native addons (N-API/NAPI): Write the hot path in C++ and call it from Node. Libraries like Sharp (image processing) and bcrypt do exactly this.
  • What Node IS great for: I/O-bound workloads where you are mostly waiting on network, disk, or database responses. A single Node process can handle 10,000+ concurrent connections because waiting on I/O does not block the thread.
// BAD -- blocks the event loop for all users
app.get('/fibonacci', (req, res) => {
  const result = fibonacci(45); // ~7 seconds of blocking
  res.json({ result });
});

// GOOD -- offloads to a worker thread pool
const Piscina = require('piscina');
const pool = new Piscina({ filename: './fibonacci-worker.js' });

app.get('/fibonacci', async (req, res) => {
  const result = await pool.run(45);
  res.json({ result });
});
What interviewers are really testing: Can you articulate the specific mechanism that makes computation problematic (event loop blocking), propose multiple solutions with trade-offs, and explain where Node actually excels? They want engineers who understand the runtime’s strengths and limitations rather than just parroting “Node is single-threaded.”Red flag answer: “Node.js can’t do computation at all, you should just use Java/Go.” This is defeatist and wrong. Node can handle computation with Worker Threads and native addons — the question is about architecture, not language wars.Follow-up:
  1. How would you detect if the event loop is being blocked in production? Name specific tools or metrics.
  2. What is the --max-old-space-size flag, and when would you tune it?
  3. You have a Node.js API that needs to resize images on upload. Walk me through three different architectures, with trade-offs for each.
Answer:Streams are one of the most powerful and underused features in Node.js. The core idea: instead of loading an entire dataset into memory, you process it piece by piece (chunks) as it flows through.
  • Four types of streams:
    1. Readable: Source of data. Examples: fs.createReadStream(), HTTP request body (req), process.stdin.
    2. Writable: Destination for data. Examples: fs.createWriteStream(), HTTP response (res), process.stdout.
    3. Duplex: Both readable and writable, independently. Example: TCP sockets (net.Socket).
    4. Transform: A duplex stream where the output is a computed transformation of the input. Examples: zlib.createGzip(), crypto.createCipheriv().
  • Why streams matter in production: Without streams, serving a 2 GB file means loading 2 GB into memory per request. With 100 concurrent users, that is 200 GB of RAM. With streams, each request uses only the highWaterMark buffer size (default 64 KB for file streams), so 100 concurrent users need roughly 6.4 MB total.
  • Backpressure is the critical concept most developers miss. If a writable stream is slower than a readable stream (e.g., writing to a slow disk while reading from fast RAM), data piles up in memory. The .pipe() method handles backpressure automatically by pausing the readable stream when the writable stream’s internal buffer is full. If you manually consume streams with .on('data'), you must handle backpressure yourself or risk memory exhaustion.
  • pipeline() vs .pipe(): Always prefer stream.pipeline() (Node 10+) over .pipe(). The pipeline function properly handles error propagation and cleanup. With .pipe(), if the writable stream errors, the readable stream is NOT automatically destroyed, leading to resource leaks.
const { pipeline } = require('stream');
const fs = require('fs');
const zlib = require('zlib');

// GOOD -- pipeline handles errors and cleanup
pipeline(
  fs.createReadStream('access.log'),     // 4 GB log file
  zlib.createGzip(),                      // Compress on the fly
  fs.createWriteStream('access.log.gz'), // Write compressed
  (err) => {
    if (err) console.error('Pipeline failed:', err);
    else console.log('Pipeline succeeded');
  }
);

// BAD -- errors are not propagated, resource leaks possible
fs.createReadStream('access.log')
  .pipe(zlib.createGzip())
  .pipe(fs.createWriteStream('access.log.gz'));
What interviewers are really testing: Whether you understand memory management in Node.js, can articulate backpressure, and know the difference between .pipe() and pipeline(). Senior candidates should mention highWaterMark and real memory impact numbers.Red flag answer: “Streams are for reading files” or inability to name all four stream types. If a candidate does not mention backpressure, they have likely never dealt with streams under real load.Follow-up:
  1. What is highWaterMark and how would you tune it for different workloads?
  2. Explain backpressure. What happens if you consume a readable stream with .on('data') and your writable destination is slow?
  3. How would you implement a custom Transform stream for, say, CSV-to-JSON conversion on a 10 GB file?
Answer:Error handling in Node.js is where most production outages originate. The approach depends entirely on the async pattern you are using, and mixing patterns is where things go wrong.
  • Callback pattern: Errors are the first argument of the callback (the “error-first callback” convention). If you forget to check err, the error is silently swallowed and your app continues in a corrupted state. This is arguably the #1 source of hard-to-debug Node.js bugs.
  • Promise pattern: Errors propagate through the .catch() chain. An unhandled rejection (a rejected Promise with no .catch()) used to silently disappear. Since Node 15, unhandled rejections crash the process by default (--unhandled-rejections=throw). This is the correct behavior — an unhandled error means your app is in an unknown state.
  • async/await pattern: Use try/catch blocks. The gotcha: if you fire off multiple async operations without await and one throws, the error becomes an unhandled rejection that escapes your try/catch.
  • EventEmitter pattern: Emitters crash the process if an 'error' event fires and no listener is registered. Always attach .on('error') to streams, sockets, and custom emitters.
  • The process safety nets: process.on('uncaughtException') catches synchronous errors that escaped all try/catch blocks. process.on('unhandledRejection') catches Promises that rejected without a handler. In production, use these to log the error and gracefully shut down (finish in-flight requests, close DB connections, then process.exit(1)). Never use them to “recover” and continue — your app state is corrupted.
// Dangerous: parallel async without proper error handling
async function riskyParallel() {
  try {
    const p1 = fetchUser(1);    // No await -- fires immediately
    const p2 = fetchUser(2);    // No await -- fires immediately
    // If p2 rejects before p1, the rejection is unhandled
    const user1 = await p1;
    const user2 = await p2;
  } catch (err) {
    // Only catches the FIRST awaited rejection
  }
}

// Safe: use Promise.allSettled or Promise.all
async function safeParallel() {
  try {
    const [user1, user2] = await Promise.all([
      fetchUser(1),
      fetchUser(2)
    ]);
  } catch (err) {
    // Catches if ANY promise rejects
  }
}
What interviewers are really testing: Do you understand the different error propagation paths for each async pattern? Can you explain why uncaughtException should NOT be used for recovery? Do you know about the Node 15 behavior change for unhandled rejections?Red flag answer: “I just use try/catch everywhere” without discussing callbacks, EventEmitters, or the process safety nets. Another red flag: using uncaughtException to “keep the server running.”Follow-up:
  1. What changed in Node 15 regarding unhandled Promise rejections, and why was that change made?
  2. How do you handle errors in a stream pipeline? What happens if a Transform stream throws mid-way?
  3. Your production Node.js app is silently failing — requests return empty responses but no errors in logs. How do you diagnose this?
Answer:A single Node.js process runs on a single CPU core. On a 16-core server, that means 15 cores are sitting idle. The cluster module solves this by forking multiple worker processes that share the same server port.
  • How it works: A master process calls cluster.fork() to create worker processes. Each worker is a full Node.js instance with its own event loop and memory. The master distributes incoming connections to workers using a round-robin algorithm (default on Linux) or lets the OS handle it (default on Windows). Workers share the same port via SO_REUSEPORT or the master accepting connections and handing them off.
  • Memory implications: Each worker is a full process, so a 16-worker cluster on a server where each process uses 150 MB means ~2.4 GB just for Node. This is why you do not blindly set workers to CPU count — profile your memory first.
  • PM2 in practice: Most production Node deployments use PM2 instead of writing cluster code manually. pm2 start app.js -i max auto-forks to the number of CPUs, handles restarts on crash, provides zero-downtime reloads via pm2 reload, and centralizes logging. Under the hood, PM2 uses the same cluster module.
  • The sticky session problem: If you use cluster with WebSockets or session-based auth (in-memory sessions), you have a problem. A user’s first request might go to Worker 1 (which stores the session), but the next request goes to Worker 3 (which has no session). Solutions: externalize sessions to Redis, use JWT tokens (stateless), or enable sticky sessions in your load balancer.
  • Cluster vs. Docker/K8s: In containerized environments, the common pattern is to run ONE Node process per container and scale at the container/pod level. This gives you better isolation, resource limits, and rolling deployments. Running cluster inside a container is double-scaling and wastes resources.
const cluster = require('cluster');
const os = require('os');

if (cluster.isPrimary) {
  const numCPUs = os.cpus().length;
  console.log(`Master process ${process.pid} forking ${numCPUs} workers`);

  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on('exit', (worker, code) => {
    console.log(`Worker ${worker.process.pid} died (code: ${code}). Restarting...`);
    cluster.fork(); // Auto-restart crashed workers
  });
} else {
  const express = require('express');
  const app = express();
  app.listen(3000, () => {
    console.log(`Worker ${process.pid} listening on port 3000`);
  });
}
What interviewers are really testing: Whether you understand multi-process architecture, session management challenges, and can compare manual clustering against modern container orchestration. Staff candidates should discuss when NOT to use clustering.Red flag answer: “Just use cluster.fork() and you get multi-threading.” Cluster creates processes, not threads. This confusion tells the interviewer you do not understand the difference.Follow-up:
  1. How does zero-downtime deployment work with the cluster module (or PM2)?
  2. Why would you choose running one Node process per Docker container over using the cluster module inside a container?
  3. How does the master process distribute incoming connections to workers? What is the difference between round-robin and OS-level scheduling?
Answer:This is not just a syntax question — the two module systems have fundamentally different loading mechanics, and mixing them is where real pain begins.
  • CommonJS (require): Synchronous loading. When Node hits a require(), it stops execution, reads the file, executes it, caches the result in require.cache, and returns module.exports. Because it is synchronous, you can require() conditionally inside an if block or dynamically based on runtime values. This is why CommonJS was originally the only option for Node — synchronous loading is fine on a server where files are local.
  • ES Modules (import/export): Asynchronous, statically analyzable. Imports are hoisted and resolved before any code executes. This means you cannot conditionally import (use import() for dynamic imports). The static nature enables tree-shaking (dead code elimination) by bundlers. In Node, you enable ESM by setting "type": "module" in package.json or using the .mjs extension.
  • Loading differences that bite you: CommonJS require returns a copy of primitive exports (numbers, strings). ES Modules export live bindings — if the exporting module changes a variable, the importing module sees the new value. This difference causes subtle bugs when migrating.
  • Interop hell: You can import a CommonJS module (Node wraps its module.exports as the default export). You CANNOT require() an ES Module — you must use await import() instead. This asymmetry means if a popular library switches to ESM-only, every CommonJS consumer must refactor their import code. This has caused significant community friction (the “pure ESM package” debate).
  • __dirname and __filename: These are CommonJS globals. They do not exist in ES Modules. Use import.meta.url with fileURLToPath() instead.
// CommonJS -- synchronous, dynamic
const config = require(`./config.${process.env.NODE_ENV}.js`);
console.log(__dirname); // Available

// ES Module -- static, async
import config from './config.js';
// Dynamic import for conditional loading
const module = await import(`./plugins/${pluginName}.js`);

// ESM equivalent of __dirname
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 understand module resolution at the runtime level? Can you explain the practical implications of synchronous vs. asynchronous loading? Have you dealt with the interop pain in real projects?Red flag answer: “import is the new syntax, require is the old one. Just use import.” This misses the entire technical picture. The interviewer wants to hear about loading mechanics, live bindings, and interop challenges.Follow-up:
  1. What is require.cache and how can it cause issues with hot-reloading or test isolation?
  2. Can you explain the “dual package hazard” and why it matters for library authors?
  3. Why are ES Modules faster for frontend bundlers but the loading difference is mostly irrelevant for backend Node.js?
Answer:Node.js uses V8’s garbage collector, which is a generational, mark-and-sweep collector. Understanding it is critical because memory leaks in Node are silent killers — your server works fine for days, then OOMs at 3 AM.
  • V8 memory structure: The heap is divided into New Space (young generation, ~1-8 MB) and Old Space (old generation, up to 1.5 GB by default on 64-bit). New objects are allocated in New Space. Objects that survive two GC cycles get promoted to Old Space.
  • Scavenge (Minor GC): Cleans New Space using a semi-space algorithm. Very fast (1-5ms). Happens frequently. This is why short-lived objects (request/response data) are essentially “free” in terms of GC pressure.
  • Mark-Sweep-Compact (Major GC): Cleans Old Space. Slower (50-100ms+ depending on heap size). Can cause noticeable latency spikes. This is why long-lived objects (caches, connection pools) need careful management.
  • Common memory leak sources in production:
    1. Global caches without eviction: Storing data in a plain object or Map that grows forever. Fix: use an LRU cache with a max size (e.g., lru-cache package).
    2. Event listener accumulation: Adding .on() listeners in a request handler without removing them. Node warns at 11 listeners (MaxListenersExceededWarning), but many teams suppress this warning instead of fixing the leak.
    3. Closures capturing large scopes: An event handler closure that accidentally captures a reference to a large object, preventing it from being GC’d.
    4. Unreferenced timers: setInterval callbacks that are never cleared.
  • Debugging toolkit:
    • process.memoryUsage() for quick heap snapshots (rss, heapUsed, heapTotal, external).
    • --inspect flag + Chrome DevTools for heap snapshots and allocation timelines.
    • node --max-old-space-size=4096 to increase the heap limit (but this just delays the OOM, it does not fix the leak).
    • clinic.js (specifically clinic doctor and clinic heapprofile) for automated diagnostics.
    • In production, expose heapUsed as a Prometheus metric and alert when it consistently trends upward.
// Classic memory leak -- global cache with no eviction
const cache = {}; // This grows forever
app.get('/user/:id', async (req, res) => {
  if (!cache[req.params.id]) {
    cache[req.params.id] = await db.getUser(req.params.id);
  }
  res.json(cache[req.params.id]);
});

// Fixed -- LRU cache with max size
const { LRUCache } = require('lru-cache');
const cache = new LRUCache({ max: 1000, ttl: 1000 * 60 * 5 });
What interviewers are really testing: Can you explain V8’s GC at a mechanical level? Have you actually debugged a memory leak, or do you just restart the server when things get slow? Do you know the tools?Red flag answer: “Node handles garbage collection automatically so you don’t need to worry about it.” This is the answer of someone who has never run a Node process for more than a few hours.Follow-up:
  1. Your Node.js service RSS grows from 200 MB to 1.2 GB over 48 hours, then OOMs. Walk me through your debugging process step by step.
  2. What is the difference between heapUsed, heapTotal, rss, and external in process.memoryUsage()?
  3. How does WeakRef and FinalizationRegistry (Node 14+) help prevent certain categories of memory leaks?
Answer:EventEmitter is the backbone of Node.js. Nearly every core module inherits from it — Streams, HTTP servers, child processes, the fs watcher. Understanding it is understanding Node’s architecture.
  • How it works internally: An EventEmitter maintains a hash map where keys are event names and values are arrays of listener functions. emit('event') iterates over the array and calls each listener synchronously, in registration order. This is the critical detail most people miss: emitting is synchronous. If you have 5 listeners and the 3rd one takes 200ms, the 4th and 5th listeners wait, and so does all code after the emit() call.
  • on vs once: on registers a persistent listener. once registers a listener that auto-removes itself after the first invocation. Internally, once wraps the listener in a function that calls removeListener after firing. Use once for initialization events (e.g., database connected) and on for recurring events (e.g., incoming data).
  • Error handling contract: If you emit an 'error' event and no listener is registered, Node throws the error and crashes the process. This is by design — unhandled errors should crash loudly, not fail silently.
  • Memory leak potential: Every .on() call adds a listener to the array. If you register listeners inside a request handler (a common mistake), the array grows with every request. Node’s default warning threshold is 10 listeners per event name (configurable via setMaxListeners()).
  • When to use EventEmitter vs. alternatives:
    • Use EventEmitter for intra-process, pub/sub-style decoupling (e.g., “when a user signs up, send email AND create audit log AND provision resources” — each in its own listener).
    • Use message queues (RabbitMQ, SQS, BullMQ) for inter-process or inter-service communication, retry logic, and persistence.
    • Use RxJS Observables when you need operators for filtering, debouncing, combining, or transforming event streams.
const EventEmitter = require('events');

class OrderService extends EventEmitter {
  placeOrder(order) {
    // Business logic
    this.emit('orderPlaced', order);
    // WARNING: All listeners execute synchronously before this line
    console.log('This runs AFTER all listeners complete');
  }
}

const service = new OrderService();
service.on('orderPlaced', (order) => sendConfirmationEmail(order));
service.on('orderPlaced', (order) => updateInventory(order));
service.on('orderPlaced', (order) => notifyWarehouse(order));

// If any listener throws, subsequent listeners do NOT run
// and the error propagates to the caller
What interviewers are really testing: Do you understand that emit is synchronous? Can you articulate when EventEmitter is the right tool vs. when you need a message queue? Have you hit the listener leak warning?Red flag answer: “EventEmitter is like a message queue for Node.js.” No — it is in-process, synchronous, and has no persistence, retry, or delivery guarantees. Confusing it with a message queue shows a fundamental architecture gap.Follow-up:
  1. You emit an event with 10 listeners attached. The 5th listener throws an error. What happens to listeners 6-10?
  2. How would you make EventEmitter listeners execute asynchronously so they do not block the emit call?
  3. When would you choose a message queue (like BullMQ or RabbitMQ) over EventEmitter for event-driven architecture?
Answer:This question tests whether you understand the Node.js bootstrapping process from OS to running code. The answer reveals how deeply you know the runtime.
  • Step 1 — OS process creation: The OS creates a new process, loads the Node.js binary (which includes V8 and Libuv), allocates memory for the heap and stack, and begins executing the Node bootstrap code in C++.
  • Step 2 — V8 initialization: V8 creates an Isolate (an independent instance of the engine with its own heap), creates a Context (the global scope where your code runs), and compiles the built-in JavaScript modules (like console, process, Buffer).
  • Step 3 — Libuv event loop creation: The event loop is initialized but NOT yet running. The thread pool is created (default 4 threads). OS-level async handles (epoll/kqueue/IOCP) are set up.
  • Step 4 — Module loading: Node resolves app.js to an absolute path, reads the file, wraps it in the module wrapper function (function(exports, require, module, __filename, __dirname) { ... }), and passes it to V8 for compilation and execution. This is why require, module, __filename, and __dirname are available — they are injected function parameters, not true globals.
  • Step 5 — Synchronous execution: V8 executes your top-level code synchronously, top to bottom. Any require() calls trigger the same wrap-and-execute process recursively. Async operations (timers, I/O) register callbacks but do not execute yet.
  • Step 6 — Event loop starts: Once all synchronous code finishes, Node enters the event loop. If there are pending async operations (timers, I/O, listeners), the loop keeps running. If there is nothing left to process (no open handles or pending callbacks), the loop exits and the process terminates with code 0.
// This is what Node ACTUALLY wraps your code in:
(function(exports, require, module, __filename, __dirname) {
  // Your app.js code runs here
  const express = require('express');
  const app = express();
  app.listen(3000); // Registers a handle -> event loop stays alive
});
What interviewers are really testing: Deep understanding of the Node.js lifecycle from process start to event loop. Do you know about the module wrapper? Can you explain why the process exits when there is nothing left to do?Red flag answer: “Node reads the file and runs it.” This is technically true but tells the interviewer nothing about your understanding of the runtime.Follow-up:
  1. Why does a Node.js process exit after running a simple script with no timers or servers, but stays alive when you call http.createServer().listen()?
  2. What is the module wrapper function and why does Node use it instead of running your code directly in the global scope?
  3. What is a V8 Isolate and why is it relevant to Worker Threads?
Answer:Graceful shutdown is one of the clearest signals of production experience. Most tutorials show you app.listen(3000) and stop there. In production, you need to handle SIGTERM, drain connections, close database pools, and exit cleanly — or your users see 502 errors and your data gets corrupted.
  • Why it matters: When Kubernetes sends SIGTERM to scale down a pod, or PM2 restarts a process, you have a window (typically 30 seconds) to finish in-flight requests before SIGKILL forcefully terminates you. If you do not handle SIGTERM, in-flight requests get dropped, database transactions are left incomplete, and message queue messages are acknowledged but never processed.
  • The shutdown sequence:
    1. Stop accepting new connections: Call server.close() — this stops the server from accepting new TCP connections but lets existing connections finish.
    2. Wait for in-flight requests: Set a timeout (10-15 seconds) for existing requests to complete.
    3. Close external connections: Close database pools, Redis connections, message queue consumers.
    4. Exit: Call process.exit(0) for clean exit or process.exit(1) if something went wrong during shutdown.
    5. Force kill safety net: Set a hard timeout (e.g., 25 seconds if K8s gives you 30) that calls process.exit(1) in case graceful shutdown gets stuck.
  • Keep-alive connections: HTTP/1.1 keep-alive connections stay open even after server.close(). You need to track active connections and destroy idle ones, or set Connection: close header on responses during the shutdown window.
const server = app.listen(3000);
const connections = new Set();

server.on('connection', (conn) => {
  connections.add(conn);
  conn.on('close', () => connections.delete(conn));
});

async function gracefulShutdown(signal) {
  console.log(`Received ${signal}. Starting graceful shutdown...`);

  // 1. Stop accepting new connections
  server.close(() => {
    console.log('HTTP server closed');
  });

  // 2. Close idle keep-alive connections
  for (const conn of connections) {
    conn.end(); // Gracefully close
  }

  // 3. Close external resources
  await database.disconnect();
  await redis.quit();
  await messageQueue.close();

  // 4. Exit clean
  process.exit(0);
}

// 5. Hard timeout safety net
function forceShutdown() {
  console.error('Forcing shutdown after timeout');
  process.exit(1);
}

process.on('SIGTERM', () => {
  gracefulShutdown('SIGTERM');
  setTimeout(forceShutdown, 25000);
});
process.on('SIGINT', () => {
  gracefulShutdown('SIGINT');
  setTimeout(forceShutdown, 25000);
});
What interviewers are really testing: Have you actually deployed Node.js to production? Do you understand the difference between SIGTERM and SIGKILL? Can you reason about what happens to in-flight requests during deployment? This question separates developers who have run node app.js in their terminal from those who have operated services at scale.Red flag answer: “I just restart the process” or “PM2 handles that for me” without being able to explain what PM2 is actually doing under the hood.Follow-up:
  1. What is the difference between SIGTERM and SIGKILL, and why can you not handle SIGKILL?
  2. How does Kubernetes interact with your graceful shutdown code? What is the terminationGracePeriodSeconds setting?
  3. What happens to WebSocket connections during a graceful shutdown? How do you handle them differently from HTTP?
Answer:Middleware is the foundational pattern for building composable HTTP pipelines in Node.js. It is not specific to Express — the concept applies to Koa, Fastify, and even raw http.createServer.
  • What it is: A middleware is a function that sits between the incoming request and the final response. It receives (req, res, next), can modify the request/response objects, execute any code, end the request-response cycle, or call next() to pass control to the next middleware. The order of app.use() calls defines the execution order, and this order matters more than most developers realize.
  • The middleware chain is a pipeline: Think of it as a stack of functions. The request flows down through each middleware until one sends a response. If no middleware sends a response and next() is called past the last one, Express sends a default 404.
  • Types of middleware (in typical order):
    1. Request parsing: express.json(), express.urlencoded(), cookie-parser.
    2. Security: helmet (sets security headers), cors (cross-origin handling).
    3. Authentication: Verify JWT tokens, session cookies.
    4. Authorization: Check user roles/permissions for the specific route.
    5. Business logic: Your route handlers.
    6. Error handling: The (err, req, res, next) four-argument middleware at the END of the chain.
  • Performance trap: Middleware runs on EVERY request that matches its mount path. A company added a logging middleware that performed a synchronous JSON.stringify on the full request body. At 5,000 req/s with 50 KB average body size, this added 40ms per request and saturated a CPU core. Moving to async logging (writing to a stream) reduced overhead to under 1ms.
  • Error middleware is special: Express identifies error handlers by their four-parameter signature (err, req, res, next). If you accidentally omit the next parameter (even if you do not use it), Express treats it as a regular middleware and your error handler never fires. This is the single most common Express debugging headache.
// Middleware execution order matters
app.use(express.json());                    // 1. Parse body
app.use(helmet());                          // 2. Security headers
app.use(cors({ origin: 'https://app.com' }));  // 3. CORS
app.use(authMiddleware);                    // 4. Auth check

app.get('/api/users', authorize('admin'), getUsers);  // 5. Route

// ERROR handler MUST be last, MUST have 4 params
app.use((err, req, res, next) => {          // 6. Error handler
  console.error(err.stack);
  res.status(err.status || 500).json({
    error: process.env.NODE_ENV === 'production'
      ? 'Internal Server Error'
      : err.message
  });
});
What interviewers are really testing: Can you explain the request lifecycle? Do you understand why ordering matters? Have you hit the 4-parameter error handler gotcha? This question reveals whether you have built real Express apps or just followed tutorials.Red flag answer: “Middleware is just functions that run before your routes.” This is technically true but shallow. The interviewer wants to hear about ordering, error handling mechanics, and performance implications.Follow-up:
  1. What happens if a middleware calls next() but also sends a response with res.json()? What error do you get?
  2. How does Koa’s middleware model (async/await with next() returning a Promise) differ from Express’s callback model?
  3. How would you implement a rate-limiting middleware that shares state across a cluster of Node.js processes?