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.
- 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.
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.
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.
- 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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)"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?
Frequently Asked Questions
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?
15 min read · try the examples if you haven't