RAW MODE / QUESTION BANK

Node.js interview questions: The event loop and async I/O

Event-loop questions rarely stop at naming phases or repeating that Node.js is single-threaded. A senior loop usually starts with a familiar concurrency prompt, then changes one variable at a time: sockets become files, a promise becomes CPU work, or healthy average latency hides a process-wide p99 spike. The interviewer is testing whether you can connect JavaScript callbacks to V8, libuv, kernel readiness, the worker pool, and the queues between them. Strong answers predict what saturates first and name the measurement that would prove it.

The questions below are built for that pressure. Each opening answer shows the polished but shallow response an AI-prepared candidate might give. The follow-up tree exposes the missing mechanism, capacity limit, or operational consequence. Read the opening answer, answer the visible follow-ups aloud, and compare your reasoning with the fully unlocked sample. If you can explain where waiting occurs, why CPU changes the throughput equation, and how microtasks affect fairness without hiding behind "async," you are operating at the level these loops reward.

Build the foundation: The event loop
Question 01Fully unlocked sample

So Node runs all your JavaScript on one thread. Then how does one process hold tens of thousands of open connections at the same time without falling over?

Knows that the waiting actually happens in the kernel and libuv, and can predict which operations scale for free and which ones quietly queue up

What an AI-prepared candidate might say

So Node runs all your JavaScript on one thread, and everything is built around non-blocking I/O and the event loop. Basically when a request comes in and your handler makes a database call or reads a file, Node hands that off to the system and registers a callback, and the main thread is immediately free to take other requests. When the operation finishes, the callback gets put in a queue and the event loop runs it once the thread is free again. So the thread never just sits idle waiting on I/O, and that is how one process keeps thousands of requests in flight at once. That's why Node does well for I/O-heavy APIs and real-time stuff. You'd usually push CPU-heavy work somewhere else, because a long computation would hold up the whole loop.

Senior

The waiting happens in the kernel, and that's the piece most people miss. When a connection comes in, libuv registers the socket's file descriptor with whatever readiness mechanism the platform has - epoll on Linux, kqueue on macOS, IOCP on Windows. Then the loop parks itself in its poll phase, sleeping inside a single syscall, till the kernel reports that some descriptor has activity. An idle connection costs you one file descriptor, some kernel socket-buffer memory, and a little bit of JS heap state. No thread, no stack. So ten thousand idle sockets are basically free, because epoll_wait scales with how many sockets have events, and idle sockets have none.

There's a second, smaller mechanism for operations which have no readiness model. Regular files always report ready, but the actual read can still block on the disk, so that work goes to libuv's thread pool - four threads by default. The fs module runs there, dns.lookup also runs there, and the async crypto functions pbkdf2, scrypt and randomBytes too. But dns.resolve* doesn't - that one goes through c-ares on non-blocking sockets. And createHash and createCipheriv simply run synchronously on whatever thread called them.

So the full picture is one V8 thread running every callback, the kernel watching your sockets, and four pool threads soaking up the file and DNS work. Once you keep this split in your head, you can start predicting things. Ten thousand concurrent HTTP requests hitting a handler that queries Postgres is fine, because that is all socket work. But ten thousand concurrent fs.readFile calls - four will run and the rest will queue. The async API looks identical in both cases, and the scaling is completely different.

Staff

Once this is running in production, what I actually care about is what stalls that one shared thread, and what tells me about it first. Any synchronous work in a handler will do it. A multi-megabyte JSON.parse, a backtracking regex, a tight loop over some big array. Every other connection's callbacks wait behind it. The symptom is p99 climbing on every endpoint at once while host CPU looks moderate, because one core is pegged and the rest are sitting idle. The tool I reach for is perf_hooks.monitorEventLoopDelay, a native histogram sampled at 10ms resolution by default. Export the p99 and the max, never the mean. And pair it with performance.eventLoopUtilization, because ELU near 1.0 with low lag means a steady stream of small callbacks is saturating the thread without any single visible stall.

The other thing that saturates is the pool. I've seen this exact incident - an auth service doing pbkdf2 on every login fills all four threads, then dns.lookup, which shares the same pool, starts queueing behind it, so outbound HTTP from the same process becomes slow and nobody connects it back to logins. Bumping UV_THREADPOOL_SIZE buys some headroom. The durable fixes are moving password hashing into worker threads using the *Sync variants (because pbkdf2Sync blocks only the worker's own thread, while async crypto called from a worker lands right back on the same process-wide pool), and getting dns.lookup out of hot paths with keep-alive agents or dns.resolve.

For capacity, it's one process per core with cluster or container replicas, connection count bounded by fd limits and per-socket memory, and throughput bounded by per-request CPU on the loop thread. Do this math before you pick the platform. 5ms of CPU per request caps a process near 200 req/s, no matter how asynchronous the I/O is. Fan-out I/O workloads fit this architecture. CPU-per-request workloads don't.

Follow-up chain

  1. Okay, so if sockets don't need threads, then why do file reads get a thread pool at all?
  2. And when that pool saturates, what breaks first? How will you spot it in your metrics?
  3. So where does await fit into all of this? What actually resumes your function?
  4. When does this model stop scaling, and what do you reach for next?
Question 02Fully unlocked sample

Say a script queues up four things - a process.nextTick, a callback on an already-resolved promise, a setTimeout with zero, and a setImmediate. What order will they fire in, and what actually decides that?

Works out the callback order from the queue structure itself, two microtask-class queues plus the loop phases, instead of reciting some memorized priority list

What an AI-prepared candidate might say

So the synchronous code always finishes first, then I believe it's process.nextTick, then the promise callback, and then the timer and the immediate at the end. Node gives process.nextTick the highest priority out of all the deferred stuff. It runs before promise microtasks, and both of these run before any timers. And setTimeout with zero doesn't literally run at zero, it gets scheduled for the next turn of the event loop, in the timers phase. setImmediate runs in a later phase, the check phase, which is the one handling immediates. So you'd see the sync code, then nextTick, then the promise, then the timeout and the immediate. Those last two can actually swap depending on process timing, it's a known quirk. Basically it's microtasks before macrotasks, with nextTick at the front of the microtasks.

Senior

I keep two structures in my head for this, because Node schedules from both of them. The first one is a pair of drain-to-empty queues - Node's own nextTick queue, and V8's microtask queue, which is where promise reactions and queueMicrotask callbacks land. After any callback returns (and the main script itself counts as a callback here), Node drains the whole nextTick queue, then the whole microtask queue, and only after that the loop gets to advance. This drain happens after every single callback. Between every timer, between every I/O event.

The second structure is the phases, which always run in one fixed order - timers, pending callbacks, poll, check, close. setTimeout entries fire in the timers phase once the loop's clock crosses their due time, and setImmediate fires in check. And here's the trap - Node clamps a timeout of 0 up to 1ms, and this clamp is what creates the main-script race. If the process takes more than a millisecond to reach the first timers phase (module loading or a first GC will easily do it), the timer is already due and it fires before check. On a fast start, the loop is there in under a millisecond and the immediate wins. Same binary, but different output from one run to the next.

Inside an I/O callback, the ambiguity just disappears. You're sitting in the poll phase, and check comes next, before the loop ever gets back to timers. So setImmediate wins there every single time.

And that gives you the answer to the puzzle. Sync code, then nextTick, then the promise callback, always in this order. Then either the timeout or the immediate, if the pair came from the main script. And if you queued that pair inside an I/O callback, then the immediate always comes first.

Staff

Three real-world things hang off this ordering. The first one is API contracts. Library code leans on nextTick to guarantee that events emit after the caller has finished attaching listeners. A constructor that emits synchronously loses events, and one that emits on a timer adds latency and reorders against other callbacks. Streams and a big chunk of core rely on this, which is why nextTick is still around despite its starvation risk. So when you're writing an emitter or a callback API, the place where you defer is part of the contract, because callers can observe whether their own synchronous code ran before your callback fired.

The second is test stability. Any assertion that depends on the main-script timeout-versus-immediate race will pass or fail based on startup timing, and it fails more under CI load, because slower startup pushes the 1ms clamp past the first timers phase. I've watched teams just keep hitting retry on those. The fix is structural - don't assert cross-phase ordering that you don't control, use fake timers for time-based logic, and when a test needs to run after all pending I/O, chain setImmediate instead of setTimeout(0).

The third is throughput. The 1ms clamp turns every setTimeout(0) hop into paid dead time, so a chunked batch job built on it spends most of its wall clock inside the timer machinery itself. setImmediate yields through the poll phase with no clamp at all. I've seen that one substitution take a migration script from hours down to minutes.

And when the ordering surprises you, just measure it. A ten-line script with timestamped logs will settle what some blog post from 2016 can't, and advice from the node --stack-size era ages badly. The queues are observable, so go and look at them directly.

Follow-up chain

  1. So why can't you predict the setTimeout(0) versus setImmediate order at the top of a script?
  2. But inside an I/O callback, setImmediate always wins. Why is that?
  3. What does the code after an await actually run as?
  4. Say a nextTick callback schedules another nextTick. When does the loop ever get to move on?
Question 03First answer included

Here's a scenario. Your Node API's p99 latency blows up on every single endpoint at the same time, but host CPU is sitting at 40 percent. What's your first hypothesis, and how do you confirm it?

Reads every endpoint slowing down at once as a blocked loop, and goes straight to loop-delay instrumentation to confirm it instead of guessing

What an AI-prepared candidate might say

That sounds like blocking the event loop, which is when some synchronous code runs long enough to delay everything else, since Node runs JavaScript on a single thread. The usual causes are the synchronous file system APIs like readFileSync, or heavy computation inside a request handler, or parsing a really large JSON payload. While that code runs, no other callback can execute. So incoming requests just wait, timers fire late, and every endpoint slows down at once, which matches what you described. The standard advice is keep handlers small, use the async versions of those APIs, and move heavy computation out of the request path. To confirm it I'd look through the code for long-running synchronous operations and check whether the latency lines up with a specific type of request. APM tracing or a profiler can help narrow down which route it is.

Senior

Everything that blocks a loop besides fs.readFileSync, from JSON.parse size thresholds to regex backtracking to hot loops, and the three symptoms that tell a blocked loop apart from slow downstreams.

Staff

How to actually catch the offending stack in production, with a profiler you can run on a degraded pod, and the guardrails that stop this whole class of bug from coming back.

Follow-up chain

  1. So the kernel is still accepting connections while the loop is blocked. What happens to all those requests?
  2. And why does host CPU look so moderate while p99 is exploding?
  3. How would you find the exact blocking code path in production, without restarting the process?
  4. Is there ever a spot where a synchronous call is actually fine?
Question 04First answer included

Tell me what actually runs on the libuv thread pool in Node. And once you know that, how do you decide what UV_THREADPOOL_SIZE should be?

Knows which operations hit the libuv pool, which ride the kernel, and which run on the calling thread, and sizes it by physical cores and measured concurrency

What an AI-prepared candidate might say

So the thread pool is for the operations the OS can't do asynchronously on its own. The main ones are the file system calls like fs.readFile, DNS lookups through dns.lookup, and I believe some of the crypto functions. Network sockets don't use the pool at all, they ride the OS async notification mechanisms, like epoll. The pool has four threads by default, so only four of these operations can run at once and the rest wait in a queue. You can raise that with the UV_THREADPOOL_SIZE environment variable, up to 1024 threads I think, which helps for services doing heavy file or crypto work. The rule of thumb I've seen is set it to the number of CPU cores. So fs.readFile looks non-blocking from your code's point of view, but the actual read is happening on a pool thread.

Senior

The full pool roster, fs, dns.lookup, pbkdf2/scrypt/randomBytes, async zlib, plus the three lookalikes that never touch it and why the env var only matters before first use.

Staff

An incident pattern where DNS queues up behind file reads, a way to prove pool saturation from latency histograms alone, and the sizing arithmetic against physical cores.

Follow-up chain

  1. Say your service hashes passwords on login and streams files on every request. What starts going wrong under load?
  2. Could you prove the pool is saturated from metrics alone, no profiler?
  3. So does cranking UV_THREADPOOL_SIZE up to 128 fix it?
  4. What about worker_threads, do they get their own pool?
Question 05First answer included

How much can you actually trust a Node timer? I set a setTimeout for 100 milliseconds and it fires at 340. Why? And while we're here, what do ref and unref really change?

Knows the timer guarantee is one-sided, never early with no bound on lateness, and reasons about ref counting and drift from how the loop schedules

What an AI-prepared candidate might say

So a setTimeout in Node really only promises a lower bound. It's saying run this callback no sooner than 100ms from now, and that's about it. The event loop checks timers once per iteration, so if the process is busy running other callbacks or collecting garbage, the timer just fires late. Firing at 340ms means the loop stayed occupied for something like 240ms past the due time, usually because some synchronous work blocked it. setInterval behaves the same way, which is why intervals drift under load. Timers also keep the Node process alive by default. Calling unref() tells Node to stop holding the process open just for that timer, which is useful for background stuff like periodic cache cleanup, where you don't want a housekeeping timer keeping the process running after everything else is done.

Senior

The scheduling mechanics behind that 340ms. Due times checked once per loop iteration, the 1ms clamp, and the re-arming rule that decides what an interval does about the periods the loop slept through.

Staff

Why 100k concurrent request timeouts cost close to nothing, thanks to per-duration timer lists, plus timer slippage as a free health metric and the unref'd-interval shutdown bug.

Follow-up chain

  1. Why does Node even clamp setTimeout(0) to 1ms? And what does that do to a yield loop built on it?
  2. So how does Node manage to keep 100k in-flight request timeouts cheap?
  3. Most of those get cancelled before they ever fire, right? Does that change the cost?
  4. Say you unref your metrics-flush interval, and the last flush before shutdown just never happens. What went wrong there?
Question 06First answer included

Your script has finished all its work, but the process just sits there and never exits. What actually keeps a Node process alive, and how does it decide it's done?

Treats process liveness as bookkeeping over refed handles, and can name the API that shows what's holding a hung process open

What an AI-prepared candidate might say

So a Node process stays alive as long as there's still pending work registered with the event loop. That means active timers, open sockets or servers, file operations that are in progress, basically anything the loop is tracking. When the loop has nothing left to do, the process exits on its own. If it's hanging, something is still registered somewhere. The common causes are a setInterval that never got cleared, an open database connection, a server that's still listening, or a stream that was never closed. The usual fix is just cleaning up when you're done. Call clearInterval, close your connections, call server.close(). You can also call unref() on a timer or a socket so it stops keeping the process alive. And if you need to force it, process.exit() ends the process immediately, plus there are the beforeExit and exit events where you can run cleanup code as it shuts down.

Senior

The refed-handle accounting that actually decides exit, why promises don't count toward it, and the exit-code-13 case hiding behind that.

Staff

A shutdown sequence that drains without hanging, a debugging workflow built on `getActiveResourcesInfo`, and the SIGTERM-in-Kubernetes coordination most services get wrong.

Follow-up chain

  1. Your process exits with code 13, no error output at all. What happened?
  2. Is there an API that tells you what's holding the loop open? How would you use it?
  3. Okay, the output says Timeout and TCPSocketWrap. What do you actually do with that?
  4. What makes calling process.exit() inside a request handler such a bad idea?
Question 07First answer included

So process.nextTick and promise callbacks can actually starve the event loop. Walk me through how that happens, and what it looks like from the outside when it does.

Knows both microtask-class queues drain to exhaustion, and can tell you which recursion patterns freeze I/O and which ones actually yield

What an AI-prepared candidate might say

So process.nextTick callbacks and promise microtasks both run before the event loop continues to its next phase, and both queues get processed completely before anything else happens. The problem is, if a nextTick callback schedules another nextTick, the new one gets processed in the same batch, so a recursive nextTick loop just runs forever and the event loop never proceeds. No timers fire, no I/O callbacks run. Same thing applies to a promise that keeps chaining onto itself. setImmediate is the safe alternative for recursive scheduling, because immediates get processed once per loop iteration and I/O runs in between. From the outside a starved loop basically looks like a frozen server. Requests time out, CPU is busy, and nothing gets logged. The general advice is keep your microtasks short and use setImmediate for repeating work.

Senior

The drain-to-exhaustion rule, entries queued mid-drain included, and why recursing with setImmediate is safe when recursing with nextTick livelocks the process.

Staff

How a finite-but-long drain shows up as a production latency spike, the ELU-plus-lag pattern that gives it away, and the one-line yield that fixes chunked loops.

Follow-up chain

  1. So why does await Promise.resolve() inside a loop still starve I/O? It's awaiting, isn't it?
  2. What's the smallest change you could make that actually yields to the loop?
  3. If nextTick can livelock a whole process, why does Node even keep it around?
Question 08First answer included

epoll and kqueue are readiness models, and IOCP is a completion model. What's the actual difference, and where does it show up in how Node scales?

Can explain readiness versus completion at the syscall level, then work out Node's connection-scaling profile straight from that model

What an AI-prepared candidate might say

So with a readiness model like epoll or kqueue, you register interest in a set of file descriptors and the kernel tells you which ones are ready to read or write, and then the application does the actual read or write itself. With a completion model like IOCP on Windows, you submit the operation up front, you hand the kernel a buffer to read into, and it notifies you once the operation has finished. Node hides both of these behind libuv, so your JavaScript sees the same callback API on every platform. The scaling result is that one thread can watch huge numbers of connections, because it never blocks on any single socket. It makes one call that comes back with whichever sockets need attention. That's basically what made the single-threaded event loop workable for the C10K problem that thread-per-connection servers struggled with.

Senior

What epoll_wait actually hands back, who runs the read afterward, and why the byte copy landing on the loop thread is what sets Node's throughput ceiling.

Staff

The broadcast-storm failure mode, budgeting per-connection memory once socket counts hit six figures, and the place TLS quietly puts crypto on your loop thread.

Follow-up chain

  1. So why don't regular files just work with epoll?
  2. Say ten thousand sockets all become readable in the same tick. What does that do to latency?
  3. And if you can't shard the process any further, what else brings the burst cost down?
  4. Where does the TLS encryption actually run, and what does that do to this whole model?
Question 09First answer included

How do you actually measure event-loop health in production? And what thresholds are worth paging a human for?

Names the real instrumentation, monitorEventLoopDelay and eventLoopUtilization, knows what each one catches, and backs every alert threshold with reasoning

What an AI-prepared candidate might say

The standard metric is event-loop lag, which is basically how much later than scheduled the loop gets around to running its callbacks. Node exposes it through perf_hooks.monitorEventLoopDelay, which gives you a histogram of the observed delays, and most monitoring libraries like prom-client already export it as an event-loop-lag metric out of the box. A healthy service shows lag in the low milliseconds. If it's sustained above roughly 100ms, the loop is getting blocked regularly and requests are queueing. There's also event loop utilization, which reports how much of its time the loop spends active versus idle. In practice you export these to your metrics system, put them on a dashboard next to request latency, and alert when the lag stays elevated. High lag means go looking for synchronous work, large payload parsing, or garbage collection pauses in the process.

Senior

How monitorEventLoopDelay actually samples, what its histogram percentiles are telling you, and the two-snapshot ELU pattern that catches saturation the delay metric misses entirely.

Staff

The actual paging thresholds with the reasoning behind them, the alert-per-pod-never-on-averages rule, and wiring a profiler to fire on breach so spikes stop going unattributed.

Follow-up chain

  1. Why is mean loop lag basically useless as a signal?
  2. Okay, ELU is reading 0.95 but p99 lag is only 8ms. What's going on there?
  3. So what do you actually do about it in that state?
Question 10First answer included

You've got a CPU-heavy job, and the server still has to keep serving requests, so you need to slice it up. setImmediate, setTimeout(0), or queueMicrotask. Pick one and defend it.

Picks the scheduling primitive from queue placement, which queue it lands in and when that drains, and defends the pick with throughput numbers

What an AI-prepared candidate might say

I'd go with setImmediate for breaking up CPU work. It schedules the next slice after the current event loop phase, so pending I/O gets to run between the slices. setTimeout(0) works too, it's just slower, because Node clamps the delay to a minimum of one millisecond and every slice pays that latency. queueMicrotask is the wrong tool here I think. Microtasks all drain before the loop moves on, so if you queue the next slice as a microtask the loop stays busy and the I/O just keeps waiting. The pattern itself is pretty simple. You process a batch of items, call setImmediate with the next batch, and repeat until the job finishes. The server stays responsive while the work makes progress. For really heavy jobs I'd probably reach for worker threads instead, since they move the work off the main thread entirely.

Senior

Where each of the three primitives actually enqueues, the recursion-safety property that makes setImmediate the chunking tool, and the job queueMicrotask really does in API design.

Staff

The slice-budget arithmetic with hrtime, a loop-delay before-and-after that proves the fix worked, and the threshold where chunking loses out to worker_threads.

Follow-up chain

  1. Walk me through why the queueMicrotask version starves I/O, exactly.
  2. So what problem is queueMicrotask actually there to solve in server code?
  3. People use process.nextTick for the same trick. Which one should you pick?
  4. At what point do you give up on slicing and just move the job to a worker?

THE BASELINE GETS YOU THROUGH QUESTION ONE.

Raw Mode trains the follow-ups.

Unlock every Senior and Staff answer, every tree answer, the debug repos, hallucination drills, design scenarios, and the framework capstone.Get NodeBook Raw Mode