Node loops come up in every backend round that touches a server, and the questions cluster tighter than candidates expect. A handful of traps carry most of the signal: where nextTick sits, why a stream quietly eats memory, and what a clean SIGTERM actually does. This list comes from the rubrics behind our Node modules and real loop reports, and each section routes you to the free notes for that band.
Where the questions land, and where to go:
| Theme | A question you will hear | Where to study |
|---|---|---|
| loop internals | order nextTick, setTimeout, Promise.then, setImmediate | node-runtime |
| async control | "these three requests are independent, speed this up" | promises |
| streams | "why does this pipe leak memory under load?" | streams & memory |
| memory | heap flat, RSS climbing: what leaked | streams & memory |
| server | where error-handling middleware goes, and why | server patterns |
| shutdown | "SIGTERM arrives, walk me through a clean exit" | server patterns |
| modules | require() an ESM file with top-level await | modules |
| throughput | one endpoint slow at p99, how you find it | backend performance |
The loop, precisely
The junior floor asks "is Node single-threaded?" and wants the nuance: one JS thread, I/O offloaded. The middle and senior bands want the phase order and the queue priorities.
Name the libuv phases, in order
Timers, pending callbacks, poll, check, close. Idle and prepare are internal. setImmediate fires in check, right after poll, which is why setImmediate beats setTimeout(fn, 0) when both are scheduled inside an I/O callback but the two race non-deterministically at the top level.
Order these four
Trap candidates rank Promise.then above process.nextTick. It is the other way around, and both drain between every phase, not just at loop end.
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
// nextTick, promise, then timeout/immediate (order varies at top level)
// nextTick queue drains first, THEN the promise microtask queue,
// both before the loop advances to the next phase
Follow-up "what happens if process.nextTick schedules another nextTick forever?" It starves the loop, I/O never runs, which is why the docs steer you to setImmediate for yielding. The language-level microtask model underneath this lives in the event loop notes; the Node-specific phases and thread pool are here.
Study Node.js runtime internals.
Async that does not lie
Speed up three independent requests
Trap awaiting in sequence when the calls do not depend on each other.
// WRONG: ~sum of latencies, serialized for no reason
const a = await fetchA();
const b = await fetchB();
const c = await fetchC();
// RIGHT: ~max latency, started together
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);
The good follow-up is failure semantics: Promise.all rejects on the first failure and abandons the rest, allSettled waits for every result and never rejects, and race ignores the losers but does not cancel them, so the losing work keeps running and can leak. Spotting a missing return inside .then() (a floating promise whose rejection escapes the chain) is the other reliable middle-band tell.
Study Promises & async.
Streams and backpressure
Why does this pipe leak memory?
Trap a hand-rolled data bridge ignores the writable's backpressure signal.
// WRONG: write() returning false is ignored, chunks pile up in the buffer
readable.on("data", (chunk) => {
writable.write(chunk); // no pause when this returns false
});
// RIGHT: pipeline wires pause/resume AND destroys every stream on error
const { pipeline } = require("node:stream/promises");
await pipeline(readable, transform, writable);
writable.write() returns false once the buffer passes highWaterMark (16 KB for byte streams), and you are supposed to stop until the drain event. .pipe() and pipeline() do that handshake for you; pipeline() also propagates errors and tears down the whole chain, where a bare .pipe() leaks file descriptors when one stage throws. Backpressure is cooperative, so a producer that ignores the false still overflows.
Heap is flat but RSS keeps climbing
Buffers live outside the V8 heap. A 50 MB Buffer barely moves heapUsed but moves external and rss by about 50 MB, so a Buffer or un-drained stream leak shows up as rising RSS with a flat heap. Chasing it in the JS heap is the wasted afternoon interviewers listen for. --max-old-space-size bounds the V8 old space, not RSS, so an off-heap leak can OOM the process without ever tripping the heap limit.
Study Streams, buffers & memory.
Server patterns
Where does error-handling middleware go?
Last, and with four arguments. Express recognizes an error handler by its arity (err, req, res, next), not its name, so dropping the fourth arg silently downgrades it to a normal handler that never catches anything.
app.use("/orders", ordersRouter);
app.use((err, req, res, next) => { // MUST be 4 args, MUST be last
res.status(err.status ?? 500).json({ code: err.code });
});
Trap async errors in Express 4. A rejected promise inside an async handler is not caught unless you try/catch and call next(err) or wrap the handler; Express 5 forwards a returned rejecting promise automatically. Errors thrown in a detached setTimeout callback are caught by neither. That contract with status codes and error envelopes is API design territory.
SIGTERM arrives
The order matters. Flip a flag so /readyz returns 503 and the load balancer stops sending new traffic, then server.close() to drain in-flight requests, then close the DB pool, then process.exit(0). Follow-up "why does the process hang?" Because server.close() does not reap idle keep-alive sockets; you need server.closeIdleConnections() (Node 18.2.0) plus a force-exit timer with .unref() so the timer does not itself keep the loop alive.
Study Node server patterns.
Modules and the loop tax
require() on an ESM file works in modern Node until that graph uses top-level await, which throws ERR_REQUIRE_ASYNC_MODULE; dynamic import() always works. Circular deps split by loader: CJS hands back a partial exports object (silent undefined far from the cause), while ESM gives you TDZ ReferenceError if you read a binding before its declaration runs, deterministic and fail-fast.
Study Modules.
The last theme is throughput. A synchronous JSON.parse on a huge body, a giant regex, or sync crypto blocks the single JS thread and spikes p99 for every client; the default libuv thread pool is 4 threads, so dns.lookup can starve fs and crypto under load. Fixing an N+1 query pattern or right-sizing a connection pool is backend performance work.
Red flags ranking promises above
nextTick; claiming.pipe()cannot OOM because backpressure is automatic; hunting a rising-RSS leak in the JS heap; and callingserver.close()the whole of graceful shutdown. Any one of these caps the score no matter how fluent the rest sounds.