Senior 9 min · March 06, 2026

Node.js Interview Questions — process.nextTick Starvation

A recursive process.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Node.js handles concurrency with a single-threaded event loop backed by libuv's multi-phase cycle, not with threads per connection
  • The six event loop phases (Timers, Pending Callbacks, Idle/Prepare, Poll, Check, Close Callbacks) determine execution order
  • process.nextTick() fires before the next phase; setImmediate() fires in the Check phase — confusing them causes I/O starvation
  • Streams process data in chunks to keep memory flat — readFile on a 4GB file crashes a 2GB server
  • The cluster module multiplies your server across CPU cores; Worker Threads parallelize CPU-heavy computation within one process
  • Biggest mistake: using async/await inside .forEach() — it doesn't wait. Use for...of or Promise.all() instead.
Plain-English First

Imagine a single incredibly fast waiter at a restaurant. Instead of standing next to one table waiting for food to cook, they take the order, drop the ticket in the kitchen, then go serve other tables. When the kitchen rings the bell, they come back and deliver. That's Node.js — one thread, never idle, always handling the next task while async work finishes in the background. The bell system is the event loop, and understanding it deeply is what separates candidates who get hired from candidates who get 'we'll be in touch.' Most engineers know the high-level pitch. Interviewers at senior level want to see if you know what happens between the bell rings.

Node.js powers Uber's real-time dispatch, Netflix's streaming APIs, and LinkedIn's mobile backend — not because it's the fastest runtime on the planet, but because it handles tens of thousands of simultaneous connections without spawning a new thread for each one. That's a fundamentally different mental model from Java or PHP, and interviewers test whether you truly understand it or just read the docs the night before.

The core problem Node.js solves is the C10K problem — handling 10,000 concurrent connections cheaply. Traditional thread-per-connection servers block a thread on every open connection. Node's non-blocking I/O model means one process can juggle thousands of network requests because it never sits around waiting — it delegates I/O to the OS through libuv and moves on to the next task.

I've been on both sides of Node.js interviews at senior and staff level, and the questions that actually differentiate candidates are not syntax questions. They are questions about what happens when things go wrong under load: why the server is alive but not responding, why memory grows for 72 hours before crashing, why a perfectly readable async function produces empty arrays in production. These are the questions this article is built around.

By the end you will be able to explain the event loop's phase sequence under pressure, describe when streams beat buffering and why, write cluster code that survives bad deploys, and sidestep the async/await traps that trip up engineers who have shipped async code for years but never had to explain exactly why it went wrong.

The Heart of Node: Mastering the Event Loop

To master Node.js, you have to stop thinking linearly. The event loop is not a simple while(true) loop checking a queue — it is a multi-phase cycle managed by libuv, and the phase a callback lands in determines when it executes relative to everything else running in the process.

When an interviewer at senior level asks about execution order, they are looking for a specific answer: the six phases, in sequence. Timers, Pending Callbacks, Idle/Prepare, Poll, Check, Close Callbacks. Most candidates know about Timers and Poll. The ones who get offers can explain why a setImmediate() inside a readFile callback always fires before a setTimeout(fn, 0) in the same callback — and what that tells you about the Check and Timer phases relative to I/O.

The question that trips up the most candidates is the difference between process.nextTick() and setImmediate(). setImmediate() executes in the Check phase, immediately after the Poll phase drains. process.nextTick() is not part of the event loop at all — it fires between phases, before the loop advances, with higher priority than any other deferred callback including resolved Promises. This distinction matters because nextTick abuse is one of the few ways you can completely freeze a Node.js process while it remains technically alive and consuming CPU.

The practical implications are concrete. Use process.nextTick() when you need a callback to run after the current synchronous operation completes but before any I/O is processed — for example, emitting an error event after a constructor returns, so callers have time to attach a listener. Use setImmediate() when you want work to happen after the current I/O round is processed. Use setTimeout(fn, delay) for actual timer-based scheduling. And never use process.nextTick() in a retry loop.

io/thecodeforge/eventloop/ExecutionOrder.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// Event Loop Execution Order Demonstration
// Run this file and trace the output against the phase descriptions.
// Understanding why each line prints when it does is the interview answer.
const fs = require('fs');

console.log('1. Script Start — synchronous, runs first');

setTimeout(() => {
  // Timer phase — fires after Poll completes and timer threshold (minimum ~1ms) expires.
  // Outside an I/O callback, order vs setImmediate is not guaranteed.
  console.log('2. setTimeout (Timer Phase)');
}, 0);

setImmediate(() => {
  // Check phase — fires after Poll drains.
  // Outside an I/O callback, order vs setTimeout(0) is OS-dependent.
  console.log('3. setImmediate (Check Phase)');
});

fs.readFile(__filename, () => {
  // Poll phase callback — we are now inside an I/O callback.
  // Inside an I/O callback, the order changes:
  // setImmediate ALWAYS fires before setTimeout here.
  // This is deterministic, not OS-dependent.
  console.log('4. File Read Callback (Poll Phase)');

  setTimeout(() => console.log('5. Nested setTimeout — Timer Phase, after next Poll'), 0);
  setImmediate(() => console.log('6. Nested setImmediate — Check Phase, this loop iteration'));
});

process.nextTick(() => {
  // Not a phase — fires immediately after the current synchronous code
  // completes, before the event loop advances to Timers.
  // This is why recursive nextTick calls starve I/O.
  console.log('7. nextTick — fires between phases, highest priority');
});

Promise.resolve().then(() => {
  // Also a microtask — fires after nextTick queue drains, before next phase.
  // In Node.js 11+, Promise microtasks run after each nextTick queue flush.
  console.log('8. Promise.resolve().then — microtask after nextTick');
});

console.log('9. Script End — synchronous, runs before any callbacks');
Output
1. Script Start — synchronous, runs first
9. Script End — synchronous, runs before any callbacks
7. nextTick — fires between phases, highest priority
8. Promise.resolve().then — microtask after nextTick
2. setTimeout (Timer Phase)
3. setImmediate (Check Phase)
4. File Read Callback (Poll Phase)
6. Nested setImmediate — Check Phase, this loop iteration
5. Nested setTimeout — Timer Phase, after next Poll
The Event Loop Is a Six-Phase Cycle — Each Phase Has a Job
  • Timers: setTimeout and setInterval callbacks fire here, but only after the delay threshold has expired — setTimeout(fn, 0) still has a minimum ~1ms delay due to OS timer resolution.
  • Pending Callbacks: deferred I/O callbacks from the previous loop iteration — primarily TCP error notifications that could not be delivered in the previous Poll phase.
  • Poll: the core I/O phase — incoming connections, file read completions, network responses, and most async callbacks land here. The loop can block here waiting for new I/O if the queue is empty and no timers are pending.
  • Check: setImmediate() callbacks fire here, immediately after Poll drains — guaranteed to fire before the next Timer phase.
  • Close Callbacks: cleanup handlers like socket.on('close') and stream destroy events fire in this final phase before the loop checks for more work.
  • process.nextTick() is NOT a phase — it fires between every phase transition with the highest priority of any deferred callback, which is exactly what makes recursive nextTick calls dangerous.
Production Insight
Recursive process.nextTick() calls prevent the loop from ever reaching the Poll phase — HTTP requests, database responses, and file reads queue indefinitely. The process is alive, burning CPU, and completely unresponsive to network traffic.
setTimeout(fn, 0) has a minimum ~1ms delay — not truly zero. Do not use it for work that must happen immediately after current synchronous code.
Rule: use setImmediate() for deferred work after I/O. Use nextTick() only for post-synchronous guarantees like error event emission after construction. Never use it in retry loops, recursive patterns, or anything that fires on I/O failures.
Key Takeaway
The event loop has six phases — Timers, Pending Callbacks, Idle/Prepare, Poll, Check, Close Callbacks — and knowing which phase each callback lands in predicts execution order.
process.nextTick() fires between phases with highest priority — it is the only deferred callback that can starve I/O when used recursively.
Choose setImmediate() for post-I/O deferred work, setTimeout() for actual delays and retry scheduling, and nextTick() only when you specifically need post-synchronous-pre-I/O timing.
Choosing Between nextTick, setImmediate, and setTimeout
IfNeed callback to run after current synchronous code but before any I/O — error event emission, deferred initialization
UseUse process.nextTick() — highest priority, fires between phases. Be certain this will not be called recursively or in a loop.
IfNeed callback to run after I/O callbacks are processed in the current loop iteration
UseUse setImmediate() — fires in the Check phase, after Poll completes. Safe to use in loops and I/O callbacks.
IfNeed a minimum delay, scheduling retries with backoff, or rate-limiting work
UseUse setTimeout(fn, delay) — fires in the Timer phase, respects the delay threshold, cannot starve I/O regardless of how frequently it fires.
IfRecursive retry on failure — API call, database query, external service
UseNever use process.nextTick(). Use setTimeout with exponential backoff and a maximum retry count. nextTick retries starve I/O; setTimeout retries coexist with it.

Data on the Move: Why Streams Are Non-Negotiable

Imagine trying to read a 4 GB log file into a server with 2 GB of available RAM. If you use fs.readFile(), the process crashes with an out-of-memory error before your callback fires — Node.js attempts to allocate a single 4 GB Buffer and the OS refuses. This is not an edge case. Log files grow. User uploads are unbounded. Data exports from large tables are not predictable in size. Any code path where the data source is external and the size is not explicitly bounded is a potential OOM crash.

Streams solve this by processing data in chunks — 64 KB by default — rather than loading everything into memory at once. The readable stream reads one chunk, the writable stream consumes it, and the readable stream reads the next. Memory usage stays flat at roughly the chunk size regardless of how large the total file is. A 4 GB file processed through a stream uses no more memory than a 4 KB file processed the same way.

The concept interviewers probe at senior level is backpressure: what happens when a readable stream produces data faster than the writable stream can consume it. Without flow control, chunks accumulate in an internal buffer that grows without bound until the process runs out of memory. The .pipe() method handles this automatically by monitoring the return value of writable.write() — when write() returns false, indicating the internal buffer has exceeded its highWaterMark, pipe() calls readable.pause(). When the writable drains and emits 'drain', pipe() calls readable.resume(). In custom stream implementations, you must wire this manually — and failing to do so is the most common mistake in custom stream code.

In 2026, the recommended approach for multi-stream pipelines is pipeline() from stream/promises rather than bare .pipe(). pipeline() propagates errors from any stream in the chain, destroys all streams on failure, and returns a Promise compatible with async/await. pipe() silently ignores errors from Transform streams in ways that are difficult to trace in production.

io/thecodeforge/streams/EfficientCopy.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// Memory-Efficient Stream Processing
// This pattern processes files of any size with constant memory overhead.
const fs   = require('fs');
const zlib = require('zlib');
const { pipeline } = require('stream/promises');
const path = require('path');

const sourceLog      = path.join(__dirname, 'massive_log.txt');
const compressedDest = path.join(__dirname, 'massive_log.txt.gz');

// ─── Recommended: pipeline() from stream/promises ───
// Advantages over .pipe():
//   - Propagates errors from any stream in the chain
//   - Destroys all streams on failure (prevents fd leaks)
//   - Returns a Promise — works with async/await and try/catch
async function compressLogFile(source, destination) {
  const readStream  = fs.createReadStream(source);        // reads in ~64KB chunks
  const gzipStream  = zlib.createGzip({ level: 6 });      // compresses each chunk
  const writeStream = fs.createWriteStream(destination);

  // pipeline() wires backpressure between all three streams.
  // If gzip falls behind, the read stream pauses automatically.
  // If the write stream fills, gzip pauses. No manual event wiring.
  await pipeline(readStream, gzipStream, writeStream);

  console.log(`Compressed: ${source} -> ${destination}`);
  return destination;
}

// ─── For comparison: what .pipe() looks like ───
// The key difference: if gzipStream emits an error, pipe() does NOT
// propagate it to writeStream or to the caller. Both streams keep running,
// the destination file is left in an incomplete state, and there is no
// indication anything went wrong unless you attach explicit error listeners.
function compressWithPipe(source, destination) {
  return new Promise((resolve, reject) => {
    const read  = fs.createReadStream(source);
    const gzip  = zlib.createGzip();
    const write = fs.createWriteStream(destination);

    // Must attach error listeners to EVERY stream individually with pipe()
    read.on('error', reject);
    gzip.on('error', reject);
    write.on('error', reject);
    write.on('finish', resolve);

    read.pipe(gzip).pipe(write);
  });
}

(async () => {
  try {
    await compressLogFile(sourceLog, compressedDest);
  } catch (err) {
    // pipeline() rejects with the first error from any stream.
    // All streams have already been destroyed at this point.
    console.error('Compression failed:', err.message);
    process.exit(1);
  }
})();
Output
Compressed: /app/massive_log.txt -> /app/massive_log.txt.gz
Backpressure Is the Concept That Separates Junior and Senior Stream Questions
When a fast readable stream overwhelms a slow writable stream, data accumulates in the writable stream's internal buffer without bound. This is backpressure failing. .pipe() handles it by monitoring write() return values and pausing the readable — but in custom Transform stream implementations, you must wire this manually. Failing to check whether write() returned false and pause the readable accordingly turns a supposedly memory-efficient stream into a memory leak that grows until the process crashes. This is the specific detail interviewers probe when they ask about custom stream implementation.
Production Insight
readFile loads the full file into a single Buffer before your code sees any data — a 4 GB file on a 2 GB server crashes instantly, before the callback fires.
Streams process in ~64 KB chunks with backpressure — memory stays at roughly the chunk size regardless of file size.
Use pipeline() from stream/promises rather than bare pipe() in production — it propagates errors from all streams and destroys them on failure. pipe() silently swallows Transform stream errors in ways that produce corrupted output files with no logged error.
Key Takeaway
Streams keep memory flat by processing data in chunks — readFile on large files crashes the process before you see any data.
Backpressure is the flow control mechanism that prevents a fast reader from overwhelming a slow writer — pipe() handles it automatically, custom implementations must handle it manually.
Use pipeline() from stream/promises rather than bare pipe() — it is the production-correct choice for any multi-stream chain.

Async/Await Pitfalls That Trip Up Even Experienced Developers

The most common Node.js interview traps involve async/await behavior that defies intuition. These are not obscure edge cases — they are patterns that cause real production bugs, and interviewers use them specifically to distinguish engineers who have debugged async code under production load from those who have only read about it.

The first trap is async/await inside .forEach(). This one has probably caused more silent production bugs than any other async pattern in the Node.js ecosystem. .forEach() calls the callback for each element but does not await the returned Promises. Every iteration fires the async function simultaneously and the forEach call returns before any of them resolve. The results array is empty. Database writes happen in unpredictable order. Error handling catches nothing because the rejected Promises are not connected to anything. The code looks completely correct when you read it.

The second trap is sequential awaits on independent operations. If you have three database queries that do not depend on each other's results and you await each one in sequence, your total request latency is the sum of all three query times. If you await them with Promise.all(), your total latency is the slowest single query. In a dashboard handler loading profile, orders, and notifications, the difference is often 300ms sequential versus 100ms parallel — and that gap widens as traffic increases.

The third trap is error handling in parallel operations. Promise.all() is fail-fast — the first rejection causes the entire call to reject, discarding results from Promises that may have already successfully resolved. In many dashboard-style UIs, this is wrong behavior. One widget failing should not blank the entire page. Promise.allSettled() waits for all Promises to settle regardless of individual outcomes, returning structured results that let you render partial data and show inline errors for specific failed components.

The fourth trap, which fewer articles mention: uncontrolled concurrency in Promise.all(). Calling Promise.all() on an array of 10,000 items creates 10,000 simultaneous operations — 10,000 concurrent database connections, 10,000 concurrent API calls, 10,000 concurrent file reads. This saturates your connection pool, triggers rate limiting, or exhausts file descriptors long before any of the operations complete. Use p-limit or a manual semaphore to cap concurrency at a sensible number.

io/thecodeforge/async/AsyncPitfalls.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
// Async/Await Pitfalls and Correct Patterns
// Each trap is shown with the wrong approach first, then the fix.

// ─────────────────────────────────────────────────────────────
// TRAP 1: .forEach() does NOT await Promises
// The most common source of silent async bugs in production Node.js code.
// ─────────────────────────────────────────────────────────────

// WRONG: This does not process users sequentially.
// Each async callback returns a Promise. forEach discards it immediately.
// The loop completes before any fetch resolves. results stays empty.
async function processUsersWrong(userIds) {
  const results = [];
  userIds.forEach(async (id) => {
    const user = await fetchUser(id); // forEach does NOT await this
    results.push(user);               // never executes before forEach returns
  });
  return results; // [] — always empty, no error thrown
}

// RIGHT — Sequential: use for...of when order matters or each step depends on the prior
async function processUsersSequential(userIds) {
  const results = [];
  for (const id of userIds) {
    const user = await fetchUser(id); // actually waits for each fetch
    results.push(user);
  }
  return results; // [user1, user2, user3, ...] in order
}

// RIGHT — Parallel: use Promise.all when operations are independent
async function processUsersParallel(userIds) {
  // All fetches start simultaneously. Total time = slowest single fetch.
  return Promise.all(userIds.map(id => fetchUser(id)));
}

// ─────────────────────────────────────────────────────────────
// TRAP 2: Sequential awaits on independent operations waste latency.
// This is the most common performance bug in async request handlers.
// ─────────────────────────────────────────────────────────────

async function loadDashboardSlow(userId) {
  const profile       = await fetchProfile(userId);      // 100ms — waits for this
  const orders        = await fetchOrders(userId);       // 150ms — then waits for this
  const notifications = await fetchNotifications(userId); //  80ms — then this
  return { profile, orders, notifications };             // 330ms total
}

async function loadDashboardFast(userId) {
  const [profile, orders, notifications] = await Promise.all([
    fetchProfile(userId),       // 100ms ─┐
    fetchOrders(userId),        // 150ms ─┤ all start simultaneously
    fetchNotifications(userId)  //  80ms ─┘
  ]);
  return { profile, orders, notifications }; // 150ms total — 55% faster
}

// ─────────────────────────────────────────────────────────────
// TRAP 3: Promise.all fails fast — use allSettled for partial results.
// One widget failing should not blank the entire dashboard.
// ─────────────────────────────────────────────────────────────

async function loadWidgetsWithAllSettled(widgetConfigs) {
  const results = await Promise.allSettled(
    widgetConfigs.map(config => fetchWidgetData(config))
  );

  // allSettled always resolves — never rejects. Each result has status.
  return results.map((result, i) => ({
    widget: widgetConfigs[i].name,
    data:   result.status === 'fulfilled' ? result.value : null,
    error:  result.status === 'rejected'  ? result.reason.message : null
  }));
}

// ─────────────────────────────────────────────────────────────
// TRAP 4: Uncontrolled concurrency — Promise.all(10000 items) creates
// 10,000 simultaneous connections. Saturates pools and triggers rate limits.
// ─────────────────────────────────────────────────────────────
async function processLargeDatasetSafely(items) {
  const { default: pLimit } = await import('p-limit'); // npm install p-limit
  const limit = pLimit(10); // maximum 10 concurrent operations at any time

  return Promise.all(
    items.map(item => limit(() => processItem(item)))
  );
}

// Simulated helpers
async function fetchUser(id)          { return { id, name: `User ${id}` }; }
async function fetchProfile(id)       { return { id, bio: 'Engineer' }; }
async function fetchOrders(id)        { return [{ id: 1, total: 99 }]; }
async function fetchNotifications(id) { return [{ msg: 'Welcome' }]; }
async function fetchWidgetData(config){ return { type: config.name, value: 42 }; }
async function processItem(item)      { return { processed: item }; }
Output
// processUsersWrong: [] — always empty, no error
// processUsersSequential: [user1, user2, user3, user4, user5] — correct order
// loadDashboardSlow: ~330ms total (sequential latency sum)
// loadDashboardFast: ~150ms total (parallel, capped at slowest)
The .forEach() + async Trap Is the Most Common Silent Bug in Production Node.js
.forEach() calls the callback and immediately discards the returned Promise. Your async function runs, but the loop does not wait for it — and because it returns void rather than the Promise, errors are swallowed too. This pattern has caused payment confirmation bugs, analytics data loss, and order processing failures in codebases I have reviewed. Always use for...of for sequential async work or Promise.all(items.map(...)) for parallel. Never .forEach() with an async callback.
Production Insight
.forEach() fires all async callbacks and discards their Promises — the results array is always empty when forEach returns, and errors are silently swallowed.
Sequential awaits on independent operations sum their latencies — Promise.all takes only the slowest, typically cutting response time by 50 to 70%.
Rule: for...of for sequential dependent operations, Promise.all for independent parallel work, Promise.allSettled when partial failures are acceptable, p-limit when concurrency must be bounded.
Key Takeaway
.forEach() does not await Promises — it is the single most common source of silent async bugs in Node.js production codebases.
Promise.all for parallel execution when all must succeed; Promise.allSettled when partial failure is acceptable; p-limit when concurrency must be bounded.
Sequential awaits on independent operations are a performance bug — parallelize with Promise.all and routinely save 50 to 70% of response time in data-fetching handlers.
Choosing the Right Async Iteration Pattern
IfEach operation depends on the result of the previous one — sequential processing
UseUse for...of with await — guarantees execution order, each step sees the prior result, errors propagate correctly through try/catch
IfOperations are independent and every one must succeed for the result to be valid
UseUse Promise.all(items.map(async (item) => {...})) — parallel execution, fails fast on first rejection, total time equals slowest operation
IfOperations are independent and partial failure is acceptable — dashboard widgets, batch enrichment
UseUse Promise.allSettled(items.map(async (item) => {...})) — parallel execution, returns all results with status, never rejects
IfNeed to limit concurrency — bulk API calls, database writes, file processing
UseUse p-limit with Promise.all — cap concurrent operations at a number that does not saturate your connection pool or trigger rate limits

Cluster and Worker Threads: Scaling Beyond a Single Core

Node.js is single-threaded, but that does not mean it is single-process or single-core. The cluster module forks one Node.js process per CPU core, all sharing the same server port through handle passing from the primary process. Each worker is a fully independent V8 instance with its own event loop, heap, and garbage collector. The primary process owns the TCP socket and distributes incoming connections to workers.

The distinction interviewers test at senior level: clustering improves I/O concurrency — more event loops, more simultaneous connections handled across cores. It does not make any individual request faster. A slow database query still takes the same time on eight workers as it does on one. If your bottleneck is the database, cluster adds zero benefit. If your bottleneck is that the single event loop cannot accept new connections fast enough, clustering multiplies your throughput proportionally to core count.

Worker Threads solve an entirely different problem: CPU-intensive computation that blocks the event loop. bcrypt password hashing, image resizing, large JSON parsing, ML inference, and cryptographic operations all occupy the event loop thread for their full duration while they run — during which no other requests are handled. Worker threads let you offload that computation to a separate thread while the event loop stays free to accept connections. The tradeoff is crash isolation: cluster workers are independent processes (30 to 80 MB each), so one crashing does not affect the others. Worker threads share the V8 heap (2 to 4 MB each) and an unhandled exception in a thread can crash the entire worker process.

In production, high-traffic services commonly use both: clustering for the outer concurrency layer and worker threads within each cluster worker for CPU-bound per-request work like bcrypt or image processing. This is a legitimate architecture, not premature complexity.

io/thecodeforge/cluster/SimpleCluster.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// Production-Aware Cluster Setup
// Includes the circuit breaker pattern that prevents fork-bomb on bad deploys.
const cluster = require('node:cluster');
const http    = require('node:http');
const os      = require('node:os');

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

  // Force round-robin on all platforms — Windows defaults to OS scheduling
  // which produces uneven distribution under bursty traffic.
  cluster.schedulingPolicy = cluster.SCHED_RR;

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

  // ─── Circuit breaker for worker restarts ───
  // Without this, a bad deploy that crashes every worker on startup
  // creates a fork-bomb: crash → fork → crash → fork → exponential process growth.
  const crashLog = [];
  const WINDOW_MS = 30_000;
  const MAX_CRASHES = 5;
  let backoffMs = 1_000;

  cluster.on('exit', (worker, code, signal) => {
    if (worker.exitedAfterDisconnect) {
      // Intentional shutdown (SIGTERM during rolling restart) — fork immediately.
      console.log(`Worker ${worker.id} gracefully exited. Replacing.`);
      cluster.fork();
      backoffMs = 1_000; // reset backoff on clean exit
      return;
    }

    // Unexpected crash — apply backoff and check circuit breaker.
    const now = Date.now();
    crashLog.push(now);
    const recentCrashes = crashLog.filter(t => now - t < WINDOW_MS);

    if (recentCrashes.length >= MAX_CRASHES) {
      console.error(
        `Circuit breaker: ${recentCrashes.length} crashes in ${WINDOW_MS / 1000}s. ` +
        'Stopping forks. Alert on-call.'
      );
      return;
    }

    console.warn(`Worker ${worker.process.pid} crashed (code: ${code}). Backoff: ${backoffMs}ms`);
    setTimeout(() => cluster.fork(), backoffMs);
    backoffMs = Math.min(backoffMs * 2, 30_000); // exponential backoff, capped at 30s
  });

} else {
  // Worker process — handles HTTP requests, each with its own event loop.
  const server = http.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end(`Handled by worker ${process.pid}\n`);
  });

  server.listen(3000, () => {
    console.log(`Worker ${process.pid} ready`);
  });

  // Graceful shutdown on SIGTERM — drain in-flight requests before exiting.
  // The primary sees exitedAfterDisconnect === true and forks a replacement
  // without applying backoff.
  process.on('SIGTERM', () => {
    server.close(() => {
      console.log(`Worker ${process.pid} drained and exiting.`);
      process.exit(0);
    });
    // Force exit after 30 seconds if connections are still open
    setTimeout(() => process.exit(0), 30_000).unref();
  });
}
Output
Primary 12340 forking 8 workers
Worker 12341 ready
Worker 12342 ready
Worker 12343 ready
Worker 12344 ready
Worker 12345 ready
Worker 12346 ready
Worker 12347 ready
Worker 12348 ready
Cluster vs Worker Threads: The Complete Interview Answer
Clustering multiplies I/O concurrency — more event loops, more connections handled simultaneously across CPU cores. Worker threads parallelize CPU-bound work within a single process — keeping one event loop free while a thread does heavy computation. They are complementary, not competing. A production service handling both high traffic and compute-intensive per-request work typically uses both: clustering for the outer concurrency layer and worker threads within each cluster worker for the CPU-bound work. Knowing where the boundary sits between them is what demonstrates genuine production depth.
Production Insight
Cluster workers share nothing — in-memory sessions, rate limit counters, and caches break silently when requests route to different workers. Externalize all shared state to Redis.
Calling cluster.fork() unconditionally in the exit handler creates a fork-bomb when a bad deploy crashes every worker on startup — add exponential backoff and a circuit breaker.
Rule: one worker per CPU core, SCHED_RR for consistent distribution, graceful SIGTERM handling for zero-downtime deploys, and a circuit breaker that stops forking after sustained crash rates.
Key Takeaway
Cluster for I/O concurrency across cores — more event loops, more simultaneous connections, proportional throughput gain.
Worker threads for CPU parallelism — offload computation without blocking the event loop.
They are complementary: use both for services that handle high traffic and compute-intensive per-request work.

Error Handling Patterns That Prevent Silent Failures

Node.js delivers errors through four separate channels: synchronous throws caught by try/catch, error-first callbacks where you check the first argument, Promise rejections caught by .catch() or await plus try/catch, and EventEmitter 'error' events caught by .on('error', handler). Failing to cover any one of these channels produces silent failures — the code runs, the operation fails, and nothing in your logs or metrics reflects it.

The most dangerous pattern in production is the unhandled Promise rejection. In Node.js 14 and earlier, an unhandled rejection produced a warning but the process continued. In Node.js 15 and later, it crashes the process by default — which is the correct behavior, because a silently failed operation that reports success to the caller is worse than a crash. The specific version of this bug that causes the most harm: an async function called without await inside a webhook handler that then responds with 200 OK. The payment confirmation was processed. The database write silently failed. The merchant never got paid. The 200 response told the payment provider everything succeeded.

The production-grade pattern is layered. Custom error classes with an isOperational flag at the domain level give you a machine-readable distinction between expected failures — validation errors, not-found responses, timeouts — and genuine bugs like null pointer dereferences and unexpected database schema mismatches. An Express global error middleware catches operational errors and responds appropriately to clients. process.on('uncaughtException') and process.on('unhandledRejection') act as the last-resort safety net that distinguishes between the two — restarting on non-operational errors while logging operational ones without crashing.

The isOperational flag is the specific detail that elevates this answer from 'knows error handling' to 'has built production error handling.' Most engineers know about try/catch and .catch(). Fewer have thought through what the process-level handler should do when it receives an error it did not expect.

io/thecodeforge/errors/LayeredErrorHandling.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
// Layered Error Handling Pattern
// This is the structure I reach for in every Express service.
// Each layer has a specific job and they compose cleanly.

// ─── Layer 1: Custom error classes with domain context ───
// isOperational: true = expected failure — log and respond, keep running
// isOperational: false/absent = bug — log full stack, restart process
class AppError extends Error {
  constructor(message, statusCode, errorCode) {
    super(message);
    this.name        = this.constructor.name;
    this.statusCode  = statusCode;
    this.errorCode   = errorCode;
    this.isOperational = true; // the flag that matters at process level
    Error.captureStackTrace(this, this.constructor);
  }
}

class ValidationError extends AppError {
  constructor(message, fieldName) {
    super(message, 422, 'VALIDATION_ERROR');
    this.fieldName = fieldName;
  }
}

class NotFoundError extends AppError {
  constructor(resourceType, resourceId) {
    super(`${resourceType} with ID ${resourceId} not found`, 404, 'NOT_FOUND');
  }
}

class ExternalServiceError extends AppError {
  constructor(serviceName, originalError) {
    super(`${serviceName} is unavailable`, 503, 'EXTERNAL_SERVICE_ERROR');
    this.originalError = originalError;
  }
}

// ─── Layer 2: asyncHandler wrapper for Express routes ───
// Wraps async route handlers so Promise rejections flow to Express
// error middleware via next(). Without this, async throws are unhandled.
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

// ─── Layer 3: Global Express error middleware ───
// Must be registered LAST, after all routes and other middleware.
// The four-argument signature (err, req, res, next) is how Express
// identifies error-handling middleware.
function globalErrorHandler(err, req, res, next) {
  const timestamp = new Date().toISOString();

  if (err.isOperational) {
    // Expected failure — log for visibility, respond to client
    console.warn(`[${timestamp}] ${err.name} [${err.errorCode}]: ${err.message}`);
    return res.status(err.statusCode).json({
      success:  false,
      error: { code: err.errorCode, message: err.message }
    });
  }

  // Unexpected bug — hide internals from client, log full stack for debugging
  console.error(`[${timestamp}] CRITICAL ${err.name}: ${err.stack}`);
  // In production: Sentry.captureException(err), DataDog.error(err), etc.
  return res.status(500).json({
    success: false,
    error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred.' }
  });
}

// ─── Layer 4: Process-level safety net ───
// These handlers catch what slipped through all other layers.
// They should be last-resort, not first-line defense.
process.on('unhandledRejection', (reason, promise) => {
  // In Node 15+, this crashes the process by default.
  // Here we log with context before the crash.
  console.error('Unhandled Promise Rejection:', reason);
  // Optionally: emit to monitoring, then let the process manager restart
});

process.on('uncaughtException', (error) => {
  console.error('Uncaught Exception — this is a bug:', error.stack);
  // Give logging a moment to flush before exiting.
  // Your process manager (PM2, systemd) will restart the process.
  setTimeout(() => process.exit(1), 1000);
});

module.exports = {
  AppError,
  ValidationError,
  NotFoundError,
  ExternalServiceError,
  asyncHandler,
  globalErrorHandler
};
Output
// GET /users/999:
// [2026-03-06T10:23:01.442Z] NotFoundError [NOT_FOUND]: User with ID 999 not found
// Response: { success: false, error: { code: 'NOT_FOUND', message: 'User with ID 999 not found' } }
// Unexpected null dereference:
// [2026-03-06T10:23:05.118Z] CRITICAL TypeError: Cannot read properties of null
// Response: { success: false, error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred.' } }
The isOperational Flag Is the Interview Answer That Shows Production Depth
The isOperational: true flag on custom errors tells your process-level handler: this is an expected failure — a validation error, a timeout, a not-found response. Log it and respond to the client, but keep the process running. If isOperational is false or missing, it is a bug that you did not anticipate — log the full stack trace and restart. This single flag is the difference between a process that restarts on every validation error and one that runs for months and only restarts when it genuinely needs to. Most candidates know about try/catch. Fewer have built the classification layer that distinguishes recoverable failures from bugs.
Production Insight
Node.js has four error channels — throw, callback, Promise, EventEmitter. Missing any one means silent failures in production, where the code reports success while the side effect never happened.
An unawaited async function produces zero errors, zero logs, and zero observable indication of failure — the most dangerous bug class in the language.
Rule: every async call must be either awaited inside a try/catch or chained with .catch(). No exceptions. Enable ESLint's no-floating-promises rule in CI to enforce this automatically.
Key Takeaway
Four error channels in Node.js: throw, callback, Promise, EventEmitter — catch all four explicitly or accept silent failures.
Custom error classes with isOperational distinguish expected failures from bugs — crash and restart only on actual bugs.
An unawaited Promise that fails is the most dangerous pattern in production Node.js — it fails silently and responds with 200 OK, causing data integrity issues that surface much later.
● Production incidentPOST-MORTEMseverity: high

process.nextTick() Recursion Starved the Event Loop and Killed the API

Symptom
The API stopped responding to all HTTP requests within three minutes of a deploy. CPU usage sat at 100% on a single core — not spiking, just pegged there continuously. The process did not crash. It was alive, consuming a full core, but completely unresponsive to any network traffic. Health checks failed. The load balancer marked all instances unhealthy and removed them from the pool. Zero traffic was served for eight minutes until the on-call engineer force-killed the process and the process manager restarted it.
Assumption
The team assumed an infinite loop in the new business logic deployed minutes before the incident. They attached --prof and spent time searching for tight computational loops in the recently changed code. The CPU profile showed 98% of time spent in process.nextTick callbacks, but the team initially interpreted this as normal microtask overhead from the new async retry logic — not as the cause of the problem itself.
Root cause
A retry mechanism for a third-party payment API call used process.nextTick() to schedule the next retry attempt on failure. When the third-party API went down entirely, every incoming request triggered a payment call, the call failed, and process.nextTick() scheduled a retry. The retry failed again and scheduled another retry via nextTick. Because process.nextTick() callbacks execute between event loop phases — before the loop advances — the retry callbacks accumulated in the nextTick queue faster than they could drain. The event loop never advanced to the Poll phase. No I/O callbacks were processed: no incoming HTTP connections, no database responses, no file reads. The process was burning a full CPU core on retry callbacks while being completely deaf to all network traffic.
Fix
Replaced process.nextTick() in the retry handler with setTimeout() with a minimum 100ms delay and exponential backoff. setTimeout() places callbacks in the Timer phase, which runs after the Poll phase has had a chance to process I/O — it structurally cannot starve the Poll phase the way nextTick can. Added a maximum of three retry attempts with delays of 100ms, 400ms, and 1600ms. Added event loop lag monitoring via perf_hooks.monitorEventLoopDelay — if lag exceeds 500ms, the /health endpoint returns 503 so the load balancer can shed load before the situation becomes an outage. The fix went in the same day. The monitoring should have been there from the beginning.
Key lesson
  • process.nextTick() callbacks execute before the next event loop phase — recursive nextTick calls starve the Poll phase and make the process completely unresponsive to I/O while it appears healthy by process metrics.
  • Use setTimeout() for retry scheduling, never process.nextTick(). setTimeout() places work in the Timer phase, which runs after I/O polling, so it cannot starve the Poll phase regardless of retry frequency.
  • Always set a maximum retry count and exponential backoff. Unbounded retries against a down dependency overwhelm whatever scheduling mechanism you use — the issue compounds under load.
  • Monitor event loop lag in production as a first-class metric. A healthy Node.js process has lag under 10ms. If it consistently exceeds 100ms, something is blocking the loop — and nextTick starvation is one of the hardest variants to diagnose without this data.
Production debug guideSymptom-driven actions for diagnosing event loop stalls, memory exhaustion, and async failures5 entries
Symptom · 01
Event loop lag exceeds 100ms — requests queuing, P99 latency spiking
Fix
Something is blocking the event loop. The two most common causes in production API servers are synchronous fs methods in request handlers and recursive process.nextTick() calls in retry logic. Search your codebase: grep -rn 'Sync(' src/ | grep -E 'readFileSync|writeFileSync|execSync|statSync'. Use node --prof to generate a V8 CPU profile, then node --prof-process isolate-*.log to analyze the tick distribution. If nextTick callbacks dominate the profile and there is no genuine recursive nextTick call, check for microtask chains from Promise resolution — a long chain of resolved Promises can produce similar starvation.
Symptom · 02
Process RSS grows steadily until OOM crash
Fix
This is a memory leak — and in Node.js services, the most common sources are buffering large files with readFile instead of streams, accumulating event listeners on long-lived EventEmitters without cleanup, and closures in request handlers retaining references to request/response objects. Take two heap snapshots with --inspect and Chrome DevTools five minutes apart. In the comparison view, sort by count delta. Growing arrays of closures referencing IncomingMessage or ServerResponse objects confirm listener leak. Growing Buffer counts confirm readFile is being used where streams belong.
Symptom · 03
async/await inside .forEach() produces unexpected results — operations run in wrong order or results are undefined
Fix
.forEach() does not await Promises. Each iteration invokes the async callback and receives a Promise back, but .forEach() discards it and continues immediately. Every async operation fires simultaneously and the loop body completes before any of them resolve — which is why the results array is empty. This is not subtle behavior; it is documented, but it bites experienced engineers constantly because the code reads as if it should work. Replace with for...of for sequential execution or Promise.all(items.map(async (item) => {...})) for parallel execution.
Symptom · 04
UnhandledPromiseRejectionWarning in stderr — process crashes in Node 15+
Fix
A Promise was rejected with no .catch() handler and no surrounding try/catch. The pattern is usually an async function called without await somewhere in a code path that is not itself async — a common occurrence in event handlers, setTimeout callbacks, and middleware that was not marked async. Search for async function calls without await: grep -rn 'async\|await\|\.catch(' src/ | grep -v node_modules. Enable ESLint's @typescript-eslint/no-floating-promises rule in CI to catch these before they reach production.
Symptom · 05
Cluster workers crash in a loop after deploy — fork-bomb behavior
Fix
The exit handler calls cluster.fork() unconditionally on every worker exit. If every worker crashes on startup due to a bad environment variable, missing config file, or port conflict in the new deployment, the primary forks a replacement immediately, which crashes immediately, which triggers another fork. The process count grows exponentially within seconds. Kill the primary immediately to break the cycle: kill -9 $(pgrep -f 'node.*cluster'). Then diagnose from the worker startup logs rather than the crash itself — the root cause is almost always in the first few lines of worker output before the crash.
★ Node.js Performance Quick DebugFast symptom-to-action reference for event loop, memory, and async issues in production.
Event loop stalled — requests queuing, health checks failing
Immediate action
Identify what is blocking the event loop — synchronous calls or nextTick starvation
Commands
node --prof app.js && node --prof-process isolate-*.log | head -50
node -e "const {monitorEventLoopDelay}=require('perf_hooks'); const h=monitorEventLoopDelay({resolution:10}); h.enable(); setInterval(()=>console.log('lag:',(h.mean/1e6).toFixed(1)+'ms'),2000)"
Fix now
Find and remove synchronous blocking: grep -rn 'Sync(' src/ | grep -E 'readFileSync|writeFileSync|execSync'. For nextTick starvation, look for process.nextTick() inside retry loops or recursive callbacks — replace with setTimeout and exponential backoff.
Memory leak — RSS climbing over hours+
Immediate action
Take heap snapshots and compare retained object counts between snapshots
Commands
kill -USR2 <pid>
node --inspect app.js
Fix now
In Chrome DevTools heap comparison view, filter for closures and EventListener objects. Growing counts referencing IncomingMessage or ServerResponse confirm listener leak — find .on() calls inside request handlers and add .off() in the cleanup path. Growing Buffer counts confirm readFile is being used where streams belong.
UnhandledPromiseRejection crash in Node 15++
Immediate action
Find the floating Promise — an async call missing await or .catch()
Commands
grep -rn 'await\|\.catch(' src/ | grep -v node_modules | grep -v test
node --unhandled-rejections=throw app.js
Fix now
Add await before the async call and wrap in try/catch, or chain .catch(). Enable ESLint no-floating-promises rule in CI so these are caught before deployment rather than in production at 3 AM.
Cluster workers dying in a loop after deploy+
Immediate action
Kill the primary immediately to stop the fork cycle before it exhausts system resources
Commands
ps aux | grep node | grep -v grep
kill -9 $(pgrep -f 'node.*cluster')
Fix now
Add exponential backoff to the exit handler before restarting. Read worker startup logs for the crash reason: pm2 logs --err --lines 100. The root cause is in the lines immediately before the crash, not in the crash line itself.
Deferred Execution Methods Compared
Featureprocess.nextTick()setImmediate()setTimeout(0)
PhaseMicrotask queue — between phases, before loop advancesCheck Phase — immediately after Poll phase drainsTimer Phase — first phase of the next loop iteration after delay expires
PriorityHighest — executes before setImmediate, before resolved Promises, before any I/OExecutes after Poll I/O callbacks, after nextTick and Promise microtasks drainExecutes after timer threshold expires — minimum ~1ms due to OS timer resolution
RiskRecursive calls starve I/O — the Poll phase never runs, the process freezes while appearing aliveSafe in loops and I/O callbacks — designed to coexist with I/O without starvation riskNot truly zero-delay — minimum ~1ms, up to ~4ms on some systems — do not rely on precision
Best forPost-synchronous guarantees — error event emission after construction, deferred initialization before I/ODeferred work after the current I/O round completes — safe alternative to nextTick in most casesRetry scheduling with explicit delay, rate limiting, debouncing, exponential backoff
Production dangerThe highest-risk deferred callback — one recursive call in a retry handler froze an entire API gatewayNo meaningful production danger — behaves predictably in all normal usage patternsMinimum delay is OS-dependent — do not use for work that must happen in a specific microsecond window
Interview answer'Not a loop phase — fires between phases with the highest priority, before I/O, before Promises''Fires in the Check phase, after Poll drains, safe in loops and I/O callbacks''Fires in the Timer phase with an OS-dependent minimum delay — not zero, not precise'

Key takeaways

1
The Event Loop is what makes Node.js scalable
understand its six phases and the execution priority of nextTick versus setImmediate versus setTimeout to predict behavior and avoid starvation.
2
Streams prevent memory exhaustion by processing data in fixed-size chunks rather than buffering entire files
readFile on unbounded input is a production OOM crash waiting to happen.
3
Node is single-threaded but not single-process
use the Cluster module for I/O concurrency across cores, and Worker Threads for CPU-bound parallelism within each worker.
4
async/await inside .forEach() is the single most common silent async bug in production Node.js
use for...of for sequential work and Promise.all for parallel work.
5
process.nextTick() fires between phases with highest priority
one recursive call in a retry handler can freeze an entire API gateway while the process appears healthy.
6
Node.js has four error channels
throw, callback, Promise, EventEmitter — missing any one of them produces silent failures that only surface under production load.

Common mistakes to avoid

5 patterns
×

Using async/await inside .forEach() loop

Symptom
Results array is empty after the loop completes. Database writes happen in unpredictable order or not at all. Errors from individual operations are swallowed entirely — no uncaught rejection, no console output, no indication that anything failed. The code looks correct on inspection, which is what makes this the most common silent async bug in production Node.js codebases.
Fix
Replace .forEach() with for...of for sequential execution where order matters or operations depend on each other's results. Use Promise.all(items.map(async (item) => {...})) for parallel execution where operations are independent and all must succeed. Use Promise.allSettled for parallel execution where partial failures are acceptable. Never pass an async callback to .forEach() — it cannot await it.
×

Neglecting error handling in Promises — floating Promises

Symptom
UnhandledPromiseRejectionWarning in stderr, process crash in Node 15 and later. In older Node.js versions, the operation appears to succeed — the endpoint returns 200 OK, no error is logged, but the side effect (database write, file creation, external API call) silently never happened. In payment or order processing flows, this produces data integrity problems that surface days or weeks later.
Fix
Every async call must be either awaited inside a try/catch block or chained with .catch(). Add process.on('unhandledRejection') as a safety net that logs with full context. Enable ESLint's @typescript-eslint/no-floating-promises rule in CI to catch unawaited async calls before they reach production.
×

Blocking the event loop with synchronous fs methods in request handlers

Symptom
P99 latency spikes under load while P50 looks acceptable. Event loop lag exceeds 100ms. Requests queue behind each other even though CPU appears low. On cloud storage with throttled IOPS — EBS, network-attached volumes — a single readFileSync that takes 2ms in development takes 200ms under production load, serializing every concurrent request behind it.
Fix
Replace readFileSync, writeFileSync, statSync, and all other Sync variants inside request handlers with their fs/promises equivalents wrapped in async functions. Cache frequently-read files in memory with a TTL to avoid repeated disk reads at all. Add monitorEventLoopDelay from perf_hooks to your health endpoint to detect future event loop stalls before they cause outages.
×

Running a single-process Node.js server on a multi-core machine

Symptom
CPU utilization maxes at 12.5% on an 8-core server — one core fully utilized, seven idle. The server handles 2,000 requests per second when it could handle 15,000 to 16,000 across all cores. Adding more RAM has no effect. Adding a bigger instance has no effect. The bottleneck is the single event loop, not the database or network.
Fix
Use the cluster module to fork one worker per CPU core with cluster.fork(). Add exponential backoff and a circuit breaker to the exit handler to prevent fork-bomb behavior on bad deploys. For CPU-intensive tasks within workers, use worker_threads to offload computation without blocking the worker's event loop.
×

Recursive process.nextTick() in retry handlers

Symptom
The process is alive at 100% CPU on a single core but completely unresponsive to all HTTP traffic. Health checks fail. The load balancer removes all instances from the pool. No crash, no error log — the process is just frozen in an endless loop of nextTick callbacks while the Poll phase never executes. Looks like an infinite loop in a CPU profile but the flame graph shows nextTick callbacks rather than application logic.
Fix
Replace process.nextTick() in retry handlers with setTimeout(fn, delay) where delay starts at 100ms minimum. Add a maximum retry count — three to five attempts is standard. Implement exponential backoff — 100ms, 400ms, 1600ms. setTimeout() places work in the Timer phase which runs after Poll, so it structurally cannot starve I/O regardless of retry frequency.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the 'Starvation' problem in the context of process.nextTick(). H...
Q02SENIOR
What is the 'Error-First Callback' pattern, and why did it become the st...
Q03SENIOR
Given two files, how would you merge them into a third file using stream...
Q04SENIOR
How does the V8 Garbage Collector interact with the Event Loop? What hap...
Q05SENIOR
What is backpressure in Node.js streams, and why does .pipe() handle it ...
Q06JUNIOR
What's the difference between fs.readFile and fs.createReadStream, and w...
Q07SENIOR
Implement a simple Rate Limiter using only the native Node.js HTTP modul...
Q01 of 07SENIOR

Explain the 'Starvation' problem in the context of process.nextTick(). How would you diagnose it in production?

ANSWER
process.nextTick() callbacks execute between event loop phases, before the loop advances to the next phase, with higher priority than any other deferred callback including resolved Promises. If a nextTick callback schedules another nextTick callback — directly or through a chain of function calls — the event loop never advances to the Poll phase. The Poll phase is where I/O callbacks land: incoming HTTP connections, database responses, file reads. The process is alive, consuming a full CPU core on nextTick callbacks, but completely unresponsive to all network traffic. Diagnosis: monitor event loop lag using perf_hooks.monitorEventLoopDelay in your health endpoint. A lag value consistently above 100ms under normal traffic is the first signal. If CPU is simultaneously pegged at 100% on one core, take a CPU profile with node --prof. Analyze it with node --prof-process isolate-*.log and look at tick distribution. If the majority of ticks are in nextTick callbacks, you have starvation. In an emergency, grep your retry and recursive callback code for process.nextTick() — it is the most common cause. Fix: replace process.nextTick() in any retry, polling, or recursive code path with setTimeout(fn, delay). Add a maximum iteration count and exponential backoff. setTimeout() places work in the Timer phase which runs after Poll, making starvation structurally impossible.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Is Node.js truly single-threaded?
02
When should you use Worker Threads instead of the Cluster module?
03
Why is require() synchronous in Node.js?
04
What is the difference between Promise.all and Promise.allSettled?
05
How do you detect if the event loop is blocked in a production Node.js service?
🔥

That's JavaScript Interview. Mark it forged?

9 min read · try the examples if you haven't

Previous
React Interview Questions
4 / 5 · JavaScript Interview
Next
TypeScript Interview Questions