Node.js Interview Questions — process.nextTick Starvation
A recursive process.nextTick() retry pegged CPU at 100% and blocked HTTP traffic -- debunking the 'nextTick is safe' myth with a real outage..
20+ years shipping production code across the stack, with years spent interviewing engineers. Notes here come from systems that actually shipped.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- 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.
process.nextTick starvation is a specific failure mode in Node.js where a recursive or deeply nested process.nextTick() callback queue prevents the event loop from ever reaching the I/O callback phase. Unlike setTimeout(fn, 0) or setImmediate(), which are deferred to later phases of the event loop, process.nextTick() callbacks are processed immediately after the current operation completes, before any I/O events or timers.
If you keep scheduling new nextTick callbacks faster than they can drain, the event loop starves — timers never fire, incoming HTTP requests never get handled, and your application effectively freezes. This is a classic Node.js interview trap because it tests whether you understand the event loop's phase ordering: timers → pending callbacks → idle/prepare → poll → check (setImmediate) → close callbacks, with nextTick interleaving between each phase transition, not within them.
Real-world production incidents often trace back to this pattern — for example, a recursive data transformer that uses nextTick to avoid stack overflow but accidentally blocks the poll phase, causing 100% CPU usage with zero throughput. The fix is usually to replace nextTick with setImmediate (which respects the event loop's phases) or to use async iteration with proper backpressure.
This concept directly connects to the article's sections on event loop mastery, stream backpressure, and async/await pitfalls — because nextTick starvation is the canary in the coal mine for deeper misunderstandings about how Node.js actually schedules work.
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.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
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.
What process.nextTick Starvation Actually Means
process.nextTick starvation is a condition where a Node.js event loop is indefinitely blocked from processing I/O callbacks because the nextTick queue is continuously replenished. The core mechanic: process.nextTick callbacks execute before any I/O or timer callbacks, and if each scheduled callback itself schedules another nextTick, the event loop never advances to poll or check phases. This is not a theoretical edge case — it's a real failure mode that can freeze a server handling high-throughput requests.
In practice, process.nextTick has a higher priority than setImmediate or setTimeout(fn, 0). The event loop drains the entire nextTick queue before moving to the next phase. If you recursively call process.nextTick without a termination condition, the loop starves. The key property: there is no built-in limit on the nextTick queue size — it will grow until memory is exhausted or the process hangs. This differs from setImmediate, which yields control after each callback.
Use process.nextTick only when you must guarantee that a callback runs before any I/O, such as when emitting an event synchronously after a constructor or when handling errors that must be surfaced before the next tick. In production systems, prefer setImmediate for deferring work unless you explicitly need the microtask ordering. Starvation is most common in recursive async patterns — always cap recursion depth or switch to setImmediate to keep the event loop healthy.
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.
// 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');
- 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.
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.
// 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); } })();
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.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.pipe() handles it automatically, custom implementations must handle it manually.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.
// 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 }; }
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.
// 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(); }); }
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.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.
// 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 };
No, Your CPU Isn't Bottlenecked — It's Your Event Loop Lagging
Stop blaming the CPU when your Node server starts choking under load. Nine times out of ten, it's not compute. It's the event loop being blocked by something stupid. A synchronous file read. A JSON.stringify on a 50MB object. A regex backtracking into infinity. The event loop is a single thread. If you tie it up, everything else waits. Every new connection. Every database callback. Every health check. Dead.
You prove this by measuring. Use perf_hooks or the built-in monitorEventLoopDelay. If your lag spikes over 40ms on a production box, you have a problem. Find the blocking call, move it off the main thread with worker_threads, or chunk it with setImmediate yield points. Don't throw hardware at a software problem.
// io.thecodeforge — interview tutorial import { monitorEventLoopDelay } from 'perf_hooks'; const histogram = monitorEventLoopDelay({ resolution: 20 }); histogram.enable(); // Simulate a blocking operation const start = Date.now(); while (Date.now() - start < 200) { /* spin */ } histogram.disable(); console.log(`Min lag: ${histogram.min / 1e6}ms`); console.log(`Max lag: ${histogram.max / 1e6}ms`); console.log(`Percentile 99: ${histogram.percentile(99) / 1e6}ms`);
fs.readFileSync in a request handler will peg your event loop. Always use the async version. If you can't, offload it to a worker thread.Dependency Injection Isn't Just Java Garbage — Your Node.js Tests Need It
You mock a database by monkey-patching the global require cache or using jest.mock. That works until it doesn't. You get false positives. You hide real integration bugs. Worse, you can't test the shape of your actual production dependencies. Stop that.
Real dependency injection in Node means passing your dependencies explicitly as constructor arguments or function parameters. A database client. A logger. A cache. You control what gets injected. In tests, you pass a mock. In production, you pass the real thing. No magic. No global state. Your unit tests become deterministic. Your integration tests actually prove something. It's not fancy. It's engineering.
Start with a simple logger pattern. Inject it everywhere. If you can't swap out a logger in a test without touching globals, you have a design problem. Fix the design.
// io.thecodeforge — interview tutorial class UserService { constructor(db, logger) { this.db = db; this.logger = logger; } async getUser(id) { this.logger.info(`Fetching user ${id}`); const user = await this.db.findUser(id); return user; } } // Production use const realDb = new Database(process.env.DB_URL); const realLogger = new Logger(); const service = new UserService(realDb, realLogger); // Test — inject fake const mockDb = { findUser: async (id) => ({ id, name: 'Test' }) }; const mockLogger = { info: (msg) => console.log(`[MOCK] ${msg}`) }; const testService = new UserService(mockDb, mockLogger); const user = await testService.getUser(42); console.log(user.name); // Test
require. Your tests will thank you with actual reliability.Buffer Overflow Is a C Problem — Until You Forget to Drain a Node Stream
You think high-memory usage in Node is always a leak. Wrong. Sometimes it's your stream that's not being drained. You pipe a readable into a writable, but the writable is slower. The internal buffer fills. Memory climbs. Process crashes. OOM killer thanks you.
The fix is simple: backpressure. Node streams handle it automatically if you pipe correctly. But if you manually write to a stream with , you must check the return value. If it returns writable.write()false, stop writing. Wait for the 'drain' event. That's the backpressure signal. Ignore it at your own risk.
I've seen teams blame express for memory leaks. Turned out they were hammering a slow MongoDB with writes and never listened for drain. A single stream.on('drain', ...) saved the cluster. Know your stream backpressure. It's not optional.
// io.thecodeforge — interview tutorial import { Writable } from 'stream'; // Slow writable — simulates a database or network const slowWritable = new Writable({ highWaterMark: 10, // small buffer to force backpressure write(chunk, encoding, callback) { setTimeout(() => { console.log(`Wrote chunk: ${chunk}`); callback(); }, 100); // slow } }); const data = ['A', 'B', 'C', 'D', 'E']; function writeLoop() { let canWrite = true; while (data.length && canWrite) { canWrite = slowWritable.write(data.shift()); } if (data.length) { slowWritable.once('drain', writeLoop); } } writeLoop();
false return, you will eventually hit a memory limit. Always handle 'drain' when the write returns false.writable.write(). Handle 'drain' when it returns false. That's how you honor backpressure in Node.Why Node.js Is Single-Threaded (And Why That’s a Good Thing)
Every junior asks: 'If Node is single-threaded, how can it handle thousands of requests?' The real question is why single-threaded won the bet against multithreaded servers.
Multithreaded servers burn CPU on context switches and shared-state locks. Node flips the script: one thread runs the event loop, offloading I/O to the kernel's thread pool. That single thread never blocks on disk reads or network calls—it just delegates and picks up the result later.
The payoff? No race conditions from shared memory. No deadlocks. Your code runs sequentially, predictably. The risk is CPU-heavy work—JSON parsing, crypto, image processing—which stalls the loop. That's why Worker Threads exist: to handle CPU tasks without nuking your throughput.
Single-threaded isn't a weakness. It's a design trade-off that exploits the fact that most servers wait—they don't compute.
// io.thecodeforge — interview tutorial function blockLoop(): void { for (let i = 0; i < 1e9; i++) {} } console.log('Before block'); blockLoop(); console.log('After block'); // Output: // Before block // ... 2 second pause ... // After block
Callback Hell Has Three Exits—Stop Writing Pyramid Code
Callback hell isn't just ugly—it's a bug factory. Nested callbacks lose error context, make logging impossible, and turn a 10-line operation into a 50-line abomination.
Three fixes, in order of production maturity:
- Promises — Chain
.then(), catch errors once. Every new Node API returns a promise. Use them. - Async/await — Syntactic sugar over promises. Add
asyncto the function,awaitthe promise, wrap in try/catch. Zero nesting. - Modularization — Extract each callback stage into a named function. This isn't a pattern—it's basic hygiene. If your function is longer than 20 lines, it's too big.
The senior move: combine all three. Use async/await for flow control, modularize the logic, and wrap the top-level handler in a promise chain for error propagation. Callback hell is a solved problem. Stop living in it.
// io.thecodeforge — interview tutorial async function readConfig(path: string): Promise<Config> { try { const raw = await fs.promises.readFile(path, 'utf8'); return JSON.parse(raw); } catch (err) { throw new Error(`Config load failed: ${err.message}`); } } // Usage const config = await readConfig('/etc/app/config.json'); console.log('Loaded:', config.env);
fs.promises, not fs.CORS in Node.js: Painful Until You Understand the Handshake
CORS (Cross-Origin Resource Sharing) errors are the #1 thing that makes frontend devs hate backend devs. The error message is cryptic: 'No 'Access-Control-Allow-Origin' header is present.'
Here's the deal: browsers enforce CORS to prevent malicious sites from reading your API responses. Your Node server must tell the browser 'yes, this frontend is allowed' by returning specific headers.
The handshake: For simple requests (GET, HEAD, POST with form data), your server just needs: - Access-Control-Allow-Origin: * (or your specific origin for production) - Access-Control-Allow-Methods: GET, POST, PUT, DELETE - Access-Control-Allow-Headers: Content-Type, Authorization
For complex requests (custom headers, JSON content-type), the browser sends a preflight OPTIONS request first. Your server must respond to OPTIONS with those same headers. If you use Express, cors middleware handles this in one line. But don't just slap * in production—specify allowed origins explicitly.
The root cause is nearly always: you forgot to handle OPTIONS, or the frontend origin isn't whitelisted.
// io.thecodeforge — interview tutorial import cors from 'cors'; import express from 'express'; const app = express(); const corsOptions = { origin: 'https://myapp.com', methods: ['GET', 'POST'], allowedHeaders: ['Content-Type', 'Authorization'] }; app.use(cors(corsOptions)); app.get('/data', (req, res) => { res.json({ message: 'CORS approved' }); }); app.listen(3000);
origin: '*' in production with credentials (cookies, auth headers). The browser will reject it. Whitelist origins explicitly—or use a dynamic origin validator.Why Node.js Is Single-Threaded — And Why That’s a Good Thing
Node.js runs JavaScript on a single thread inside the event loop. This is by design, not a limitation. Multithreading introduces race conditions, deadlocks, and context-switching overhead. For I/O-bound workloads — database queries, file reads, network requests — a single thread with asynchronous callbacks is more efficient than spawning a thread per request. Node offloads blocking operations to the system kernel via libuv, which uses a thread pool internally. Your JavaScript never blocks. The tradeoff: CPU-heavy tasks will stall the event loop. To solve that, offload them to Worker Threads or a child process. Single-threaded simplicity gives you predictable performance and no lock contention.
// io.thecodeforge — interview tutorial import asyncio async def mock_io(): await asyncio.sleep(0.1) # non-blocking I/O return "done" async def main(): results = await asyncio.gather(mock_io(), mock_io(), mock_io()) print(results) # ['done', 'done', 'done'] asyncio.run(main()) # single-threaded event loop
Package.json: The Manifest That Controls Your App
Package.json is the metadata file every Node project requires (no pun). It declares dependencies, scripts, version, entry point, and license. When you run npm install, npm reads dependencies and devDependencies to build node_modules. The scripts field lets you define shortcuts: npm start, npm test, npm run build. Without package.json, you have no reproducable builds, no version locking, no shared team conventions. Lockfiles (package-lock.json) pin exact versions to prevent 'works on my machine' bugs. Think of package.json as the contract between your code and the runtime environment.
// io.thecodeforge — interview tutorial import json pkg = { "name": "my-service", "version": "1.0.0", "scripts": { "start": "node index.js", "test": "jest" }, "dependencies": { "express": "⁴.18.0" } } with open('package.json', 'w') as f: json.dump(pkg, f, indent=2) print('package.json created')
process.nextTick() Recursion Starved the Event Loop and Killed the API
- 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.
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 --prof app.js && node --prof-process isolate-*.log | head -50node -e "const {monitorEventLoopDelay}=require('perf_hooks'); const h=monitorEventLoopDelay({resolution:10}); h.enable(); setInterval(()=>console.log('lag:',(h.mean/1e6).toFixed(1)+'ms'),2000)"kill -USR2 <pid>node --inspect app.jsgrep -rn 'await\|\.catch(' src/ | grep -v node_modules | grep -v testnode --unhandled-rejections=throw app.jsps aux | grep node | grep -v grepkill -9 $(pgrep -f 'node.*cluster')| Feature | process.nextTick() | setImmediate() | setTimeout(0) |
|---|---|---|---|
| Phase | Microtask queue — between phases, before loop advances | Check Phase — immediately after Poll phase drains | Timer Phase — first phase of the next loop iteration after delay expires |
| Priority | Highest — executes before setImmediate, before resolved Promises, before any I/O | Executes after Poll I/O callbacks, after nextTick and Promise microtasks drain | Executes after timer threshold expires — minimum ~1ms due to OS timer resolution |
| Risk | Recursive calls starve I/O — the Poll phase never runs, the process freezes while appearing alive | Safe in loops and I/O callbacks — designed to coexist with I/O without starvation risk | Not truly zero-delay — minimum ~1ms, up to ~4ms on some systems — do not rely on precision |
| Best for | Post-synchronous guarantees — error event emission after construction, deferred initialization before I/O | Deferred work after the current I/O round completes — safe alternative to nextTick in most cases | Retry scheduling with explicit delay, rate limiting, debouncing, exponential backoff |
| Production danger | The highest-risk deferred callback — one recursive call in a retry handler froze an entire API gateway | No meaningful production danger — behaves predictably in all normal usage patterns | Minimum 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' |
| File | Command / Code | Purpose |
|---|---|---|
| io | const fs = require('fs'); | The Heart of Node |
| io | const fs = require('fs'); | Data on the Move |
| io | async function processUsersWrong(userIds) { | Async/Await Pitfalls That Trip Up Even Experienced Developer |
| io | const cluster = require('node:cluster'); | Cluster and Worker Threads |
| io | class AppError extends Error { | Error Handling Patterns That Prevent Silent Failures |
| CheckEventLoopLag.py | const histogram = monitorEventLoopDelay({ resolution: 20 }); | No, Your CPU Isn't Bottlenecked |
| InjectDependency.py | class UserService { | Dependency Injection Isn't Just Java Garbage |
| BackpressureCheck.py | const slowWritable = new Writable({ | Buffer Overflow Is a C Problem |
| event_loop_blocked.py | function blockLoop(): void { | Why Node.js Is Single-Threaded (And Why That’s a Good Thing) |
| callback_hell_fixed.py | async function readConfig(path: string): Promise | Callback Hell Has Three Exits |
| cors_middleware.py | const app = express(); | CORS in Node.js |
| event_loop_demo.py | async def mock_io(): | Why Node.js Is Single-Threaded |
| generate_package.py | pkg = { | Package.json |
Key takeaways
Common mistakes to avoid
5 patternsUsing async/await inside .forEach() loop
Neglecting error handling in Promises — floating Promises
Blocking the event loop with synchronous fs methods in request handlers
Running a single-process Node.js server on a multi-core machine
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
Interview Questions on This Topic
Explain the 'Starvation' problem in the context of process.nextTick(). How would you diagnose it in production?
What is the 'Error-First Callback' pattern, and why did it become the standard before Promises arrived?
Given two files, how would you merge them into a third file using streams while ensuring you don't exceed 50MB of RSS memory?
pipeline() from stream/promises chained sequentially: await pipeline(readStream1, writeStream) followed by await pipeline(readStream2, appendWriteStream). pipeline() handles error propagation and stream cleanup, which bare pipe() does not.
Never use fs.readFile to load either file into memory — a single large file could easily exceed 50MB. Verify memory stays bounded by logging process.memoryUsage().rss in a setInterval during the merge. The value should stay roughly constant rather than climbing proportionally with file size. For backpressure safety, pipeline() handles this automatically — the read stream pauses when the write stream's internal buffer fills.How does the V8 Garbage Collector interact with the Event Loop? What happens during a 'Stop-the-world' event?
What is backpressure in Node.js streams, and why does .pipe() handle it automatically?
writable.write(). When the writable stream's internal buffer exceeds its highWaterMark threshold — 64KB by default for byte streams — write() returns false. .pipe() interprets this as a backpressure signal and calls readable.pause(), stopping data flow from the source. When the writable stream drains its buffer below the threshold, it emits a 'drain' event. .pipe() listens for this event and calls readable.resume() to restart the flow.
In custom Transform stream implementations, you must replicate this exact logic manually: check whether this.push() or writable.write() returns false, pause the upstream source when it does, and resume on drain. This is the specific place where custom stream implementations most commonly have memory leaks — engineers implement the transform logic correctly but skip the backpressure wiring, and the bug only manifests under sustained load when the reader consistently outpaces the writer.
In production code, prefer pipeline() from stream/promises over bare pipe() — it adds error propagation across all streams in the chain, which pipe() does not provide.What's the difference between fs.readFile and fs.createReadStream, and when would you choose one over the other?
pipeline() from stream/promises.
The practical threshold I use: if I can guarantee with certainty that the file will always be under 1MB, readFile is simpler and acceptable. If there is any uncertainty about file size — especially if the size is determined by external input — use createReadStream unconditionally.Implement a simple Rate Limiter using only the native Node.js HTTP module and an in-memory store.
Date.now() and allow the request. If it exists and the elapsed time since startTime exceeds your window (say 60,000ms), reset count to 1 and startTime to now. If it exists, within the window, and count has reached your limit (say 100 requests), respond with 429 Too Many Requests and a Retry-After header. Otherwise, increment count and allow the request.
Critically important detail for production: this implementation only works for a single-process server. In a cluster with eight workers, each worker has its own Map, so the effective rate limit is 8 times your configured limit — 800 requests per minute instead of 100. For clustered services, externalize the counter to Redis using INCR and EXPIRE for atomic cross-process rate limiting. For sliding window accuracy, use a sorted set with ZADD and ZREMRANGEBYSCORE inside a Lua script so the check and increment happen atomically in a single round-trip.
Also add a cleanup interval — setInterval(() => { const cutoff = Date.now() - windowMs; for (const [ip, data] of map) { if (Date.now() - data.startTime > cutoff) map.delete(ip); } }, windowMs) — to prevent the Map from growing without bound as IP addresses accumulate over time.Frequently Asked Questions
Node.js JavaScript execution is single-threaded — your code runs on one thread and the event loop cycles on that same thread. But the runtime is not strictly single-threaded. libuv maintains a thread pool — four threads by default, configurable via UV_THREADPOOL_SIZE — that handles operations which cannot be made non-blocking at the OS level: file I/O, DNS resolution, and certain crypto operations. These operations are delegated to thread pool threads by libuv and their completion callbacks are queued back to the main event loop thread when they finish. So your JavaScript is always single-threaded, but the underlying I/O is not.
Use Cluster to scale web servers — fork one process per CPU core, share the server port, handle more concurrent connections. Each worker gets its own event loop and handles I/O independently. Use Worker Threads to offload CPU-intensive computation within a single process — bcrypt hashing, image resizing, large data transformations, ML inference. Cluster workers are isolated processes (30 to 80MB each) with full crash isolation. Worker threads share the V8 heap (2 to 4MB each) and a crash in a thread can affect the entire process. In production, high-traffic services that also do compute-intensive per-request work typically use both.
Module loading happens at startup, before the server begins handling requests. Making require() asynchronous would require awaiting every import, make circular dependency resolution significantly more complex, and add overhead to a one-time operation that is already cached after the first load. Since require() caches the exports object after the first call, subsequent require() calls for the same module return the cached result instantly from memory without any I/O. The synchronous cost only occurs once per module per process lifetime. ES module dynamic import() is asynchronous and is the correct choice when you genuinely need to load a module conditionally at runtime.
Promise.all fails immediately on the first rejection — it rejects with that error and discards results from Promises that may have already resolved successfully. This is the correct behavior when every result is required for the operation to make sense — loading required dependencies, executing a batch transaction where partial success is worse than complete failure. Promise.allSettled waits for every Promise to settle regardless of individual outcomes, returning an array where each element is either { status: 'fulfilled', value } or { status: 'rejected', reason }. Use allSettled when partial failure is acceptable and you want to handle each result individually — dashboard widgets, batch enrichment, non-critical background operations.
Use perf_hooks.monitorEventLoopDelay to measure event loop lag as a continuous metric. A healthy Node.js process under load typically has lag under 10ms. Values consistently above 50ms indicate something is competing with the event loop. Values above 200ms indicate a serious stall — synchronous fs calls, recursive nextTick, or a CPU-heavy operation running on the main thread. Expose the lag value in your /health endpoint and alert when it exceeds your threshold — 100ms is a reasonable starting point. This gives your load balancer and APM tools visibility into event loop stalls before they cause customer-visible outages, rather than detecting the problem from failed health checks after the damage is already done.
20+ years shipping production code across the stack, with years spent interviewing engineers. Notes here come from systems that actually shipped.
That's JavaScript Interview. Mark it forged?
12 min read · try the examples if you haven't