Event loop, streams & scaling Node
Questions in this set 11
- 01Explain Node's event loop. What are the phases, and how is it different from the browser's?
- 02What blocks the event loop in production, and how do you detect it?
- 03cluster vs worker_threads vs child processes — when do you use each?
- 04Explain streams and backpressure. Why does this code lose data?
- 05How do you handle errors correctly in Node?
- 06Write a graceful shutdown.
- 07What is the difference between dependencies and devDependencies, and how do you keep a Node app secure?
- 08How do you scale a Node API, and what breaks first?
- 09Express, Fastify or NestJS — how do you choose?
- 10How does middleware ordering work, and what goes wrong?
- 11What is AsyncLocalStorage and why does it matter?
Node is currently the most in-demand backend runtime in hiring, and its interviews are unusually predictable: if you cannot explain the event loop and when to reach for a worker thread, you will not get the senior offer. Everything below is what that conversation actually contains.
Explain Node's event loop. What are the phases, and how is it different from the browser's?
Node's loop is libuv's, and it runs in ordered phases, each with its own callback queue:
- timers —
setTimeout,setIntervalcallbacks whose threshold has elapsed. - pending callbacks — some deferred system callbacks (certain TCP errors).
- idle / prepare — internal.
- poll — retrieve new I/O events and run their callbacks. This is where Node spends most of its time, and it will block here waiting for I/O if there is nothing else scheduled.
- check —
setImmediatecallbacks. - close callbacks —
socket.on("close")and friends.
Between every phase transition, Node drains two microtask queues: process.nextTick first, then promises.
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
// From the main module: order is NOT deterministic — it depends on how long
// process startup took relative to the 1ms timer threshold.
require("fs").readFile(__filename, () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate")); // ALWAYS first — we are already
}); // in poll, and check comes nextDifferences from the browser worth naming: the browser has no phases and no process.nextTick, it interleaves rendering between macrotasks, and it takes exactly one macrotask per turn — Node drains a phase's whole queue. process.nextTick running before promise microtasks is Node-specific, and starving the loop with recursive nextTick is a real way to hang a server.
Follow-up: "Is Node single-threaded?" Your JavaScript is. The runtime is not: libuv keeps a thread pool (4 by default, UV_THREADPOOL_SIZE) that executes file I/O, DNS lookups via getaddrinfo, and the crypto/zlib async APIs. Network I/O does not use the pool — it uses epoll/kqueue. This is why a burst of bcrypt.hash calls can starve file reads: they share four threads.
What blocks the event loop in production, and how do you detect it?
Anything synchronous and long: a big JSON.parse, JSON.stringify of a large object, synchronous crypto (crypto.pbkdf2Sync, bcrypt.hashSync), fs.readFileSync, a giant for loop, template rendering of a huge page, and catastrophic regex backtracking — which is also a denial-of-service vector (ReDoS) when the input is user-controlled.
The symptom is distinctive: latency rises across every endpoint at once, including health checks, and it does not correlate with database time.
Detection:
import { monitorEventLoopDelay } from "node:perf_hooks";
const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();
setInterval(() => {
metrics.gauge("eventloop.lag.p99", h.percentile(99) / 1e6); // ns -> ms
h.reset();
}, 10_000);Event loop lag is the single most important Node metric. Under 10 ms at p99 is healthy; sustained hundreds of milliseconds means something is blocking. Also clinic doctor, 0x for flamegraphs, and --prof for a V8 profile.
The fixes, in order: do less work (stream or paginate instead of parsing 50 MB); use the async variant so libuv's pool handles it; move it to a worker_thread; or move it out of the request path entirely into a queue.
cluster vs worker_threads vs child processes — when do you use each?
| isolation | memory | best for | |
|---|---|---|---|
cluster |
separate processes, one port shared | separate heaps | scaling I/O-bound request handling across CPU cores |
worker_threads |
threads in one process | separate heaps, can share via SharedArrayBuffer |
CPU-bound work (image processing, parsing, crypto) without blocking the main loop |
child_process |
separate process, own stdio | separate | running other programs, untrusted or crashy code |
The senior answer is that they compose: cluster (or your container orchestrator) to use all cores, and worker threads inside each instance for heavy computation. In containers, note that you usually do not want cluster — run one process per container and let Kubernetes do the scaling, because a cluster primary hides individual worker crashes from the orchestrator and complicates graceful shutdown.
// offloading CPU work, the modern way
import { Worker } from "node:worker_threads";
const runTask = (data) => new Promise((res, rej) => {
const w = new Worker("./resize-worker.js", { workerData: data });
w.once("message", res);
w.once("error", rej);
w.once("exit", (code) => code !== 0 && rej(new Error(`exit ${code}`)));
});Costs to mention: workers have startup cost (~10-30 ms) so you pool them rather than creating one per request, and everything passed is structured-cloned unless it is a SharedArrayBuffer or a transferred ArrayBuffer.
Explain streams and backpressure. Why does this code lose data?
// BAD: unbounded memory. Reads as fast as the disk allows, writes as fast as
// the socket accepts, and buffers the difference in RAM.
readable.on("data", (chunk) => writable.write(chunk));writable.write() returns false when its internal buffer exceeds highWaterMark. Ignoring that return value is exactly how a Node service reading a 2 GB file to a slow client ends up OOM-killed — the difference between read speed and write speed accumulates in memory.
Backpressure is the mechanism for propagating "slow down" upstream, and pipe/pipeline implement it for you:
import { pipeline } from "node:stream/promises";
import { createGzip } from "node:zlib";
await pipeline(
createReadStream(input),
createGzip(),
res, // pipeline destroys every stream on error — pipe() does not
);Always use pipeline (or pipe plus careful error handling on every stream). The classic bug with .pipe() is that an error in the middle of the chain leaves the other streams open and leaking file descriptors.
Stream types: Readable, Writable, Duplex (a socket), Transform (gzip, a parser). Object mode carries objects rather than buffers. And know why streams matter at all: constant memory regardless of payload size, and time-to-first-byte instead of time-to-last-byte.
How do you handle errors correctly in Node?
Four distinct channels, and mixing them up is the most common source of silent failures:
// 1. Synchronous — try/catch works
try { JSON.parse(body); } catch (e) { … }
// 2. Promise / async — try/catch with await, or .catch
await doThing().catch(handle);
// 3. Callbacks — error-first convention
fs.readFile(p, (err, data) => { if (err) return cb(err); … });
// 4. EventEmitter — an 'error' event with NO listener CRASHES the process
stream.on("error", handle); // never optionalThen the process-level safety net, which is for logging and shutdown, not for control flow:
process.on("unhandledRejection", (reason) => { log.fatal({ reason }); shutdown(1); });
process.on("uncaughtException", (err) => { log.fatal({ err }); shutdown(1); });The rule: after an uncaughtException the process is in an unknown state — log it, stop accepting connections, drain, and exit so the orchestrator restarts you cleanly. Keeping the process alive after an unhandled exception is how you get corrupted state. Since Node 15, unhandled rejections terminate by default, which is the right behaviour.
Separate operational errors (a timeout, a 404 from an upstream, invalid input — expected, handle them) from programmer errors (a TypeError, a null dereference — a bug, crash and restart).
Write a graceful shutdown.
const server = app.listen(3000);
const connections = new Set();
server.on("connection", (c) => { connections.add(c); c.on("close", () => connections.delete(c)); });
async function shutdown(signal) {
log.info({ signal }, "shutting down");
server.close(); // stop accepting new connections
setTimeout(() => connections.forEach(c => c.destroy()), 10_000).unref(); // force after grace
await Promise.allSettled([queue.close(), db.end(), redis.quit()]);
process.exit(0);
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));Details interviewers listen for: server.close() stops new connections but waits for in-flight requests; keep-alive connections will not close on their own, so you need the forced destroy after a grace period; the readiness probe should start failing before you stop accepting, so the load balancer drains you first; and in Docker, SIGTERM only reaches your process if it is PID 1 or you use an init — a shell-form CMD swallows it and you get SIGKILL after 10 seconds, which is the most common cause of "we drop requests on every deploy".
What is the difference between dependencies and devDependencies, and how do you keep a Node app secure?
dependencies ship to production; devDependencies are build/test only and are excluded by npm ci --omit=dev. peerDependencies declare what the host app must provide (for libraries).
Supply chain is where Node interviews get serious:
npm ci, notnpm install, in CI — it installs exactly the lockfile, fails ifpackage.jsonand the lockfile disagree, and is faster.--ignore-scriptsin CI where possible; postinstall scripts are the standard malware vector.npm audit/ Dependabot / Snyk, and actually triage the output rather than ignoring it.- Pin versions, review what a new dependency pulls in (
npm ls --all | wc -lis often shocking), and prefer fewer, better-maintained packages. - Run as a non-root user, keep secrets out of the image, and set
NODE_ENV=production(Express skips view caching and verbose errors otherwise).
How do you scale a Node API, and what breaks first?
Ordered by what actually happens:
- The event loop blocks on something synchronous — usually JSON, crypto or a regex. Fix before scaling anything.
- The database connection pool is exhausted because each Node process holds its own pool and you scaled to 20 pods. Pool size × pods must stay under the database limit.
- Memory: the default old-space heap cap (around 4 GB on 64-bit, but often lower in containers) plus unbounded caches or accumulated buffers. Set
--max-old-space-sizeto fit the container limit, or the OOM killer arrives before V8 ever runs a full GC. - libuv's 4-thread pool saturates under heavy crypto or file I/O.
- Keep-alive and socket limits, and
server.maxRequestsPerSocket/headersTimeoutmisconfigurations behind a proxy.
Then the standard moves: horizontal scaling behind a load balancer, caching, moving work to a queue (BullMQ), read replicas, and a CDN. Nothing Node-specific — which is itself the point worth making.
Express, Fastify or NestJS — how do you choose?
Express is the default: minimal, universally understood, enormous middleware ecosystem, and now on v5 with proper async error propagation. Fastify is meaningfully faster (schema-based serialisation and validation via JSON Schema) with a better plugin/encapsulation model — worth it when throughput matters or you want schema-first APIs. NestJS brings Angular-style structure, dependency injection, decorators and strong TypeScript integration — it pays off on large teams and large codebases, and is overhead on a five-endpoint service.
Choose by team size and codebase lifespan, not benchmarks. Say that, because it is the answer the question is fishing for.
// Express 5 finally handles this correctly; in Express 4 an async throw is unhandled
app.get("/users/:id", async (req, res) => {
const user = await db.getUser(req.params.id);
if (!user) throw new NotFound();
res.json(user);
});
app.use((err, req, res, next) => { // 4 args = error middleware
req.log.error({ err });
res.status(err.status ?? 500).json({ code: err.code ?? "internal" });
});How does middleware ordering work, and what goes wrong?
Express middleware is a stack executed in registration order; each either responds or calls next(). Consequences:
express.json()must come before any route readingreq.body— a missing body parser produces a confusingundefined, not an error.- Error-handling middleware (four arguments) must be registered last, or it never sees the error.
- Auth middleware placed after a route does not protect it. Mount protection on the router, not per-handler, so a new route is protected by default.
- Forgetting
next()hangs the request until the client times out — no error, no log, just a stuck socket. This is a favourite debugging question. helmet, CORS and rate limiting go near the top so they apply to everything, including 404s.
What is AsyncLocalStorage and why does it matter?
It is Node's mechanism for request-scoped context that survives across await boundaries — the correct way to carry a request id, tenant or user through call stacks without threading an argument through every function.
import { AsyncLocalStorage } from "node:async_hooks";
export const ctx = new AsyncLocalStorage();
app.use((req, res, next) => ctx.run({ requestId: req.id, userId: req.user?.id }, next));
// anywhere downstream, with no plumbing
log.info({ ...ctx.getStore() }, "charging card");Why it matters in an interview: the naive alternatives are a module-level variable (which leaks between concurrent requests — a cross-tenant data bug) or passing a context object through twenty functions. AsyncLocalStorage is also what OpenTelemetry uses to propagate trace context, so knowing it explains how distributed tracing works in Node at all.