A backend engineer at a 40-person logistics startup once got stuck on one question for almost 25 minutes in her Node.js phone screen: why doesn't one slow database call freeze every other request the server is handling. She had the right instinct (something about the event loop) but couldn't trace the actual mechanism, and the interviewer kept pulling the thread instead of moving on. She got the offer three weeks later anyway, mostly because she said "I'm not sure, let me think out loud" instead of guessing and hoping it landed.
That question, in some form, shows up in almost every list of Node.js interview questions, from four-person seed startups to companies running thousands of instances behind a load balancer. Node's whole pitch, handling a lot of concurrent I/O without spinning up a thread per request, rests on a mechanism most candidates can name but can't explain under pressure. Interviewers know this, so they push on it.
Node.js isn't a niche skill either. It was used by 48.7% of professional developers in the 2025 Stack Overflow Developer Survey, more than any other web framework or technology listed in that section (Stack Overflow Developer Survey 2025). This guide covers 24 Node.js interview questions across the eight areas that come up most: the event loop, async patterns, streams, modules, Express, error handling, scaling, and npm package security. I've flagged one spot below where I think the standard prep advice is a bit stale. I could be wrong about that, and I say so where it matters.
The event loop and non-blocking I/O
This is the section that decides most Node.js interviews, not because it's the hardest material, but because candidates memorize the one-liner ("Node is single-threaded but non-blocking") without being able to walk through what actually happens underneath it. Interviewers know the one-liner too. They ask follow-ups specifically to find out if you do.
Easy questions
15Your JavaScript executes on one thread, but Node offloads the actual blocking work, file system access, DNS lookups, some crypto operations, to the operating system's async APIs (epoll on Linux, kqueue on macOS, IOCP on Windows) or to a small libuv thread pool (default size 4) when the OS doesn't have a native async version of the operation. When that work finishes, its callback gets queued back onto the single JS thread.
So "single-threaded" only describes where your code runs, not where the waiting happens. The waiting is handled elsewhere, by the kernel or by worker threads inside libuv, not by your application code.
Callback hell is the deeply nested pyramid you get when each async step depends on the previous one and you keep passing anonymous functions inline: callback inside callback inside callback, each one indented further, error handling duplicated at every level. It's a readability and maintainability problem more than a functional one, the code still runs.
Fixes, roughly in order of how teams actually adopt them: name your callback functions instead of nesting anonymous ones, use Promise chaining to flatten the structure, or use async/await to make asynchronous code read top to bottom like synchronous code. Most modern Node codebases just standardize on async/await and wrap any remaining callback-based APIs with util.promisify().
Memory footprint and time-to-first-byte. fs.readFile() loads the entire file into a Buffer before your callback runs at all, so a 3 GB file needs roughly 3 GB of memory and the client waits for the whole read before getting a single byte. A stream processes the file in chunks (64 KB by default for file streams), so memory use stays flat regardless of file size and the client starts receiving data almost immediately.
const fs = require('fs');
app.get('/download', (req, res) => {
const stream = fs.createReadStream('./big-export.csv');
stream.pipe(res); // chunks flow to the client as they're read from disk
stream.on('error', (err) => {
res.status(500).end('Stream failed');
});
});require() (CommonJS) is synchronous, resolves and executes modules at the moment it's called, and can be used conditionally inside if-blocks or functions. import (ES Modules) is statically analyzed at parse time, before any code runs, which is what allows features like tree-shaking and top-level await, but also means import statements can't be conditional; you need the dynamic import() function for that. File-wise, Node treats .cjs as CommonJS and .mjs as ESM regardless of the package.json setting, and .js follows whatever "type" field is set in the nearest package.json (defaulting to CommonJS if it's absent).
Middleware is a function with the signature (req, res, next) that sits in the request/response cycle and can inspect or modify the request, end the response, or call next() to pass control to the next middleware in the stack. Express executes middleware in the exact order they're registered, which is why placement (logging before auth, auth before the route handler) actually matters.
const express = require('express');
const app = express();
app.use((req, res, next) => {
console.log(`${req.method} ${req.url}`);
next(); // must call next(), or the request hangs forever
});
app.use((req, res, next) => {
if (!req.headers.authorization) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
});
app.get('/profile', (req, res) => {
res.json({ user: 'demo' });
});The most common bug candidates write live in front of the interviewer: forgetting to call next(), which leaves the request hanging with no response and no error.
app.use() mounts middleware (or a router) for a path prefix and runs regardless of HTTP method, GET, POST, PUT, all of them match. app.get() registers a handler for the GET method on an exact route path only. A common mistake is using app.use('/users', handler) expecting it to behave like a GET-only route; it will also match POST /users/anything unless the handler explicitly checks the method.
Both work, and it comes down to how many awaited calls are in the function. try/catch around a block with several awaits handles all of them with one error path, which is usually cleaner than chaining .catch() after each individual call. Mixing styles inconsistently across a codebase is the actual problem, not either style on its own. A lot of teams standardize on try/catch plus a small wrapper (or a library like express-async-errors on Express 4) so route handlers don't repeat the same boilerplate 40 times.
dependencies are packages your application needs at runtime, in production. devDependencies are tools only needed during development, test runners, linters, bundlers, that don't ship with the deployed app. Running npm install --production (or setting NODE_ENV=production) skips devDependencies entirely.
package-lock.json locks the exact resolved version and integrity hash of every package in your full dependency tree, direct dependencies and every transitive one underneath them. Without it, two installs of the same package.json can silently pull different minor or patch versions of nested dependencies, which is exactly the gap attackers exploit.
Node.js is a JavaScript runtime built on Google's V8 engine that lets you run JS outside a browser, on a server or in a CLI tool. The big difference isn't the language, it's what's available around it. In a browser you get window, document, the DOM, and fetch inside a sandboxed security model. In Node you get process, module/require or ES modules, Buffer, filesystem access via fs, network sockets via net and http, and no DOM at all.
Node also ships libuv, which gives it the event loop, a thread pool for things like file I/O and DNS, and async primitives that make non-blocking I/O practical on a single JS thread. So when someone says Node is single-threaded, they mean the JS execution is single-threaded. The actual I/O work often happens off that thread in libuv or the OS, and results come back through the event loop as callbacks or resolved promises.
npm install reads package.json, resolves versions against semver ranges, updates package-lock.json if needed, and can add or upgrade packages. It's meant for active development, when you're adding a dependency or bumping a version.
npm ci is built for reproducible builds. It deletes node_modules first, then installs strictly from package-lock.json, failing hard if package.json and the lock file don't match, and it never modifies the lock file. That's why CI pipelines and Docker builds should use npm ci: it guarantees every build gets the exact same dependency tree, and it's usually faster because npm skips the resolution step.
Both are range operators. ~1.4.2 allows patch-level updates only, so anything from 1.4.2 up to but not including 1.5.0. ^1.4.2 allows minor and patch updates, so anything from 1.4.2 up to but not including 2.0.0, as long as the leftmost non-zero digit doesn't change.
The exception people forget is 0.x versions. npm treats the second digit as the breaking one there, so ^0.4.2 only allows patch updates, 0.4.x, because pre-1.0 packages are assumed to break on minor bumps too. That's why teams sometimes pin exact versions for 0.x dependencies rather than trusting caret ranges.
Node exposes environment variables through process.env, a plain object of string key-value pairs inherited from the shell or process manager that started the app, PM2, Docker, or systemd. In development, a package like dotenv reads a .env file and copies its values into process.env at startup. In production those same variables get set directly by the hosting platform or orchestrator instead.
Secrets don't belong in package.json, in code, or in a committed .env file, because anything checked into git is effectively public forever, even if you delete it later, since it stays in the commit history. The practical fix is .env in .gitignore, a .env.example with dummy keys for onboarding, and real secrets injected through your deploy pipeline or a secrets manager instead of being baked into the repo.
fs.readFileSync blocks the entire event loop until the read completes, then returns the data directly. fs.readFile is asynchronous, it takes a callback, or you promisify it with fs.promises.readFile, and lets the event loop keep processing other work while the disk I/O happens.
In a script that runs once and exits, like a build tool or a CLI, the sync version is fine and often simpler. Inside a server handling concurrent requests, using the sync version on every request stalls every other in-flight request for however long that read takes, which is why request handlers should almost always use the async or promise-based versions.
It's the convention where an async function's callback is always called with the error as the first argument and the result as the second, function(err, data). If there's no error, the first argument is null, otherwise it holds an Error object and the second argument is usually undefined.
This shows up throughout Node's core APIs, fs.readFile(path, (err, data) => {...}) for example, and it exists so error handling is consistent and impossible to accidentally skip. You check if (err) first, every time, instead of every library inventing its own shape for reporting failures. Promises and async/await largely replaced this pattern in application code, but it's still the backbone of Node's built-in modules and a lot of older or lower-level libraries.
A Buffer is Node's way of representing raw binary data, a fixed-length chunk of memory outside the V8 JS heap, used for things JavaScript strings can't cleanly handle: reading a file's raw bytes, receiving TCP socket data, working with images, or handling protocols like WebSockets where you need exact byte control.
You'd reach for a Buffer when you're doing I/O with binary data. fs.readFile without an encoding option returns a Buffer, and modules like net and http hand you Buffer chunks in their data events. You can convert one to a string with buffer.toString('utf8') and create one from a string with Buffer.from('hello', 'utf8'). Binary data isn't inherently text, so treating raw bytes as UTF-8 when they aren't can corrupt data silently, which is the reason Buffer stays a distinct type from strings.
Medium questions
25Node's event loop, provided by the libuv library, runs through a fixed set of phases each iteration: timers (setTimeout/setInterval callbacks whose time has elapsed), pending callbacks (some deferred system-level callbacks), idle/prepare (internal use), poll (retrieve new I/O events, execute I/O callbacks), check (setImmediate callbacks), and close callbacks (socket.on('close') and similar). Between every phase, and between every individual callback within a phase, Node drains the microtask queue: process.nextTick callbacks first, then resolved Promise callbacks.
Interviewers usually don't want the phase names recited from memory. They want to see you connect a phase to a real symptom, like why a setTimeout(fn, 0) can fire after an I/O callback even though you scheduled it "first."
Yes, functionally. An async function always returns a Promise, and await pauses execution of that function (not the whole program) until the awaited Promise settles, then resumes with the resolved value or throws the rejected error. Under the hood it's built on generators and the microtask queue, the same queue that regular .then() callbacks use.
What changes is ergonomics, not capability. Error handling moves from .catch() chains to ordinary try/catch blocks, and sequential logic reads without the chaining. That's a real win for readability, but it hides the async nature of the code a bit, which is why some teams still argue about which style to standardize on.
Promise.all() waits for every promise to resolve and rejects immediately if any one of them rejects, you lose the results of the ones that succeeded. Promise.allSettled() waits for all of them regardless of outcome and gives you an array of {status, value} or {status, reason} objects, so nothing gets silently dropped. Promise.race() resolves or rejects as soon as the first promise settles, whichever way it goes. Promise.any() (less commonly asked) resolves as soon as the first one succeeds and only rejects if all of them fail.
The practical question interviewers like: "you're calling three independent APIs and want to keep going even if one fails, which do you use?" That's allSettled, and candidates who default to Promise.all() out of habit usually miss it.
Readable (a source you read data from, like fs.createReadStream), Writable (a destination you write data to, like fs.createWriteStream or the HTTP response object), Duplex (both readable and writable, independently, like a TCP socket), and Transform (a duplex stream that modifies data as it passes through, like zlib.createGzip()).
Most candidates can name Readable and Writable. Transform is the one that separates a surface-level answer from someone who's actually built a pipeline, since it's the type you reach for whenever data needs to change shape mid-flight (compression, encryption, parsing a format line by line).
Yes, with some rules. An ESM file can import a CommonJS module directly (require()-style modules get wrapped so their module.exports becomes the default export). A CommonJS file cannot use static import syntax at all, but it can load an ESM module using the dynamic, asynchronous import() function, since ESM modules can't be require()'d synchronously. Named exports from a CJS module only work in ESM if Node can statically detect them, which isn't always reliable for modules that build their exports object dynamically.
I'd avoid mixing them by choice in a new project. It's manageable, but every mixed-module bug report I've seen traces back to someone assuming interop is symmetrical when it isn't.
Express recognizes error-handling middleware by its arity, a function with exactly four parameters, (err, req, res, next), is treated specially and only invoked when next(err) is called or a synchronous error is thrown inside a route handler. It must be registered last, after all your routes.
app.get('/orders/:id', async (req, res, next) => {
try {
const order = await Order.findById(req.params.id);
if (!order) {
const err = new Error('Order not found');
err.status = 404;
throw err;
}
res.json(order);
} catch (err) {
next(err); // hand off to the error-handling middleware below
}
});
app.use((err, req, res, next) => {
const status = err.status || 500;
res.status(status).json({ error: err.message });
});Since Express 5, async route handlers that throw automatically forward the error to next(), removing the need for try/catch in a lot of cases, though plenty of production apps still run on Express 4, where that safety net isn't there.
Operational errors are expected failure modes in a working system: a database connection timing out, a file that doesn't exist, a request with invalid input, an external API returning a 503. You handle these gracefully, log them, return a proper error response, maybe retry. Programmer errors are bugs: calling a function with the wrong argument type, a typo in a variable name, a broken assumption somewhere in the logic. These shouldn't be caught and quietly swallowed, they should surface loudly, because the fix is changing the code, not handling the error better.
Interviewers use this distinction to check whether your instinct is "wrap everything in try/catch and move on" or "know which failures are worth recovering from and which ones mean something is actually broken."
cluster lets a Node process fork multiple child processes (workers), each with its own event loop, memory space, and V8 instance, that share the same server port. Incoming connections get distributed across workers (round-robin on most platforms), so a multi-core machine running a single Node app can actually use more than one core, since a single Node process is otherwise limited to one. It's most useful for CPU-bound or heavily concurrent stateless HTTP servers running on one machine without an orchestrator already handling horizontal scaling.
On September 8, 2025, an attacker phished the npm account of a well-known maintainer (publishing packages under the handle Qix) using a fake two-factor-authentication reset email, then published malicious versions of chalk, debug, and roughly 16 other widely used packages. The compromised code intercepted browser wallet calls and network responses to redirect cryptocurrency transactions to attacker-controlled addresses. Those packages collectively see more than 2 billion downloads a week, though the actual financial damage was reported around $500, since the community caught and reverted it within about two hours (Socket.dev incident writeup).
The lesson isn't really about chalk or debug specifically. It's that trusting a package because "everyone uses it" is a popularity signal, not a security one, and a compromised maintainer account can push malicious code to millions of installs before anyone notices.
Run npm audit (or a dedicated tool like Socket or Snyk) as part of CI. Running it locally once in a while catches less than people assume. Commit your lockfile and use npm ci in deploy pipelines so builds are reproducible instead of re-resolving versions. Pin exact versions for anything security-sensitive rather than trusting caret ranges blindly. Review what postinstall scripts a new dependency runs before adding it, that's a common injection point. And keep your dependency tree smaller than you think you need, every transitive package is a maintainer account you're implicitly trusting.
A database connection is expensive to open, a TCP handshake, auth negotiation, sometimes TLS, so opening a fresh one per request would tank throughput. A connection pool, like pg.Pool for Postgres or the pool built into mysql2, keeps a set of already-open connections around and hands them out to whichever query needs one, returning them when the query finishes.
Pool size matters because it's a tradeoff between two failure modes. Too small, and requests queue up waiting for a free connection even though the database could handle more load, adding latency. Too large, and you can overwhelm the database itself, since Postgres has a hard max_connections limit and each connection carries its own memory overhead on the server side. A common mistake is running several Node instances, say 8 PM2 workers, each with a pool of 20, not realizing that's potentially 160 connections hitting one database, often more than max_connections allows. Pool size should be sized against total connections across all app instances, not per instance in isolation.
Session-based auth stores session state on the server, typically in Redis or a database, and gives the client an opaque session ID in a cookie. Every request, the server looks up that ID to find out who's logged in. JWT-based auth instead issues a signed token containing the user's claims, id, roles, expiry, and the server verifies the signature on each request without needing a lookup, since the token itself carries the data.
The tradeoff is revocation versus statelessness. Sessions are trivial to revoke, delete the row or Redis key and the user is logged out everywhere instantly. JWTs are stateless by design, great for horizontally scaling an API without a shared session store, but that same property makes revocation awkward. A compromised token stays valid until it expires unless you build a denylist, which reintroduces server-side state, or keep access tokens short-lived and pair them with a revocable refresh token. For most product apps, sessions with a fast store like Redis are simpler and safer by default. JWTs earn their complexity in multi-service architectures where you don't want every service calling back to an auth server on every request.
In Express, file uploads are usually handled with multer, which parses multipart/form-data bodies and gives you access to uploaded files via req.file or req.files, either buffered in memory or streamed to disk depending on the storage engine you configure.
The security issues cluster around a few things. Never trust the filename or extension the client sends, someone can name a PHP shell photo.jpg if your server later serves that directory statically or a downstream process trusts the extension, so validate actual file content when it matters. Enforce a file size limit in the multer config (limits: { fileSize }), otherwise someone can exhaust memory or disk by uploading a huge file. Generate your own filenames on the server rather than using the client-supplied one, to avoid path traversal and filename collisions. And if files land on disk before being moved to permanent storage like S3, make sure that temp directory isn't publicly servable and gets cleaned up, orphaned temp files are a classic way disks silently fill up in production.
When you require() a module, Node resolves the file path, executes it once, and caches the resulting module.exports object keyed by that resolved path. Every subsequent require() of the same file, from anywhere in your app, gets the exact same cached object back, not a fresh copy.
This is why a common pattern for singletons in Node is just exporting an instance rather than a class, module.exports = new Database() in a db.js file, since every file requiring db.js shares that one instance. It's convenient, but it bites people when they expect isolation, like requiring a config module in a test, mutating it for that test, and being surprised later tests see the mutation because everything shares the same cached object. It also means module-level state, like an in-memory counter, persists across requires within one process but not across separate processes, like PM2 cluster workers, which trips people up when they assume in-memory state is shared across their whole cluster and it isn't.
The simplest production approach is a token bucket or sliding window counter keyed by something like IP address or API key, tracked in a fast store like Redis so it works correctly across multiple app instances. A single-process in-memory counter works fine for one instance but silently stops being accurate the moment you run more than one Node process behind a load balancer, since each process only sees its own slice of traffic.
A typical setup uses express-rate-limit with a Redis store, configured per route since different endpoints deserve different limits. A login endpoint should be tighter than a general read endpoint, because brute-forcing credentials is the attack you're actually trying to stop there. It's also worth returning a proper 429 Too Many Requests with a Retry-After header rather than a generic error, so well-behaved clients can back off correctly instead of hammering you in a retry loop.
CORS is a browser-enforced mechanism, the server doesn't need it to protect itself from other servers, it exists to control which origins a browser is allowed to let JavaScript make requests to. Without a matching CORS header, the browser blocks the frontend JS from reading the response, even though the request often already reached your server.
In Express, the cors package handles this, but the common mistake is setting Access-Control-Allow-Origin to a wildcard on an API that also accepts credentials, cookies or auth headers, which the CORS spec explicitly forbids combining with a wildcard origin, and browsers reject it. The correct pattern is an explicit allowlist of origins, or a function that checks the incoming origin against a list, plus credentials true only if you actually need cookies sent cross-origin. Also remember CORS is not an auth mechanism, it doesn't stop a non-browser client like curl or another server from hitting your API, so it should never be your only defense on a sensitive endpoint.
V8's heap is split into generations based on the idea that most objects die young. New objects go into a small new space, sometimes called the young generation, and a fast, frequent scavenge GC runs there, since most short-lived objects, like a variable inside a function that finishes and returns, never need to survive past that. Objects that survive a couple of scavenges get promoted to old space, which is collected less often but with a heavier mark-sweep-compact pass, since scanning a large old space frequently would be expensive.
For a long-running Node process, this matters practically because old space garbage collection pauses, while usually just milliseconds, can show up as latency spikes under load if your process is holding onto a lot of long-lived data, large caches, big arrays that never shrink. The default heap size in older Node versions was capped around 1.5 to 2GB for old space unless you passed --max-old-space-size, which is why people hit heap out of memory crashes on servers with plenty of RAM available, the OS wasn't the limit, V8's default cap was.
The classic ones: event listeners that get added on every request but never removed, an EventEmitter accumulating listeners forever, which Node will actually warn you about past 10 listeners by default. Module-level caches or maps that grow unbounded because nothing ever evicts old entries. Closures that capture a large object just to use one small piece of it and keep the whole thing alive as long as the closure exists. And timers or intervals that get created repeatedly without ever being cleared.
A subtler one specific to Node is global variables or singletons that accumulate references to request-scoped objects, like storing the last N requests in an array for debugging and forgetting to cap the array length, so it grows without bound as traffic continues. None of these crash immediately, they show up as a slow, steady climb in memory usage over hours or days until the process gets OOM-killed, which is why they're often caught in staging load tests rather than quick manual testing.
When a process manager, Kubernetes, PM2, or systemd, wants to stop or restart a Node process, it sends SIGTERM first, then SIGKILL after a grace period if the process hasn't exited. If you don't handle SIGTERM, Node's default behavior is to just die immediately, dropping any in-flight requests, so the client sees a connection reset.
A graceful shutdown listens for SIGTERM, stops accepting new connections with server.close(), lets in-flight requests finish, closes database pools and any open sockets, then exits.
process.on('SIGTERM', async () => {
server.close(() => {
pool.end().then(() => process.exit(0));
});
setTimeout(() => process.exit(1), 10000).unref();
});The timeout matters. server.close() waits for all connections to end naturally, but if a client has an open keep-alive connection that never closes, you'd hang forever without a forced exit as a fallback.
Validation should happen at the boundary, right where untrusted input enters the system, which for an Express API means the request handler or a middleware wrapping it, before that data touches your business logic or database layer. Validating deeper in the stack, inside a service function say, means malformed data has already traveled further through your code before you catch it, and you end up duplicating checks in multiple places.
Libraries like Zod or Joi buy you a single schema that documents the expected shape, which doubles as living documentation, consistent structured error messages instead of ad hoc strings, and in Zod's case, TypeScript types inferred directly from the schema so your validation and your types can't drift apart. Hand-rolled checks tend to get inconsistent across a codebase, one route checks typeof x === 'string', another forgets to check for an empty string, another doesn't trim whitespace, and that inconsistency is exactly where bugs and security gaps creep in.
A transaction groups multiple database operations so they either all succeed or all roll back together, which matters whenever an operation touches more than one row or table that needs to stay consistent, like debiting one account and crediting another. With Prisma, that's prisma.$transaction([...]) for a batch of independent operations, or the interactive form, prisma.$transaction(async (tx) => { ... }), when later operations depend on results from earlier ones in the same transaction.
The gotcha people hit is forgetting that inside an interactive transaction, every query must go through the tx client passed into the callback, not the original prisma client, because using the outer client runs that query outside the transaction, on its own connection, defeating the point entirely. Another common mistake is doing slow, unrelated work inside the transaction callback, an external API call, a file upload, which holds a database connection open the whole time and can exhaust your pool under load. Transactions should stay short and touch only the database.
Polling, the client asking if anything's new on an interval, is the simplest to build and works everywhere, but it wastes requests when nothing's changed and adds latency up to your poll interval. It's fine for low-frequency updates where a 10 to 30 second delay is acceptable.
Server-Sent Events give you a one-way stream from server to client over plain HTTP, the server pushes events as they happen and the client just listens, no polling needed. It's simple to implement, keep the connection open with a text/event-stream content type and write events, works through most proxies without special config, and auto-reconnects in the browser's EventSource API. The catch is it's one-directional, the client can't send data back over that same connection. WebSockets are full-duplex, both sides can send anytime over one persistent connection, which is what you need for something like a chat app or collaborative editing, but they need more infrastructure care. Load balancers need sticky sessions or a shared pub/sub layer like Redis if you're running multiple Node instances, since a WebSocket connection is pinned to one server process, unlike stateless HTTP requests any instance can handle.
Once a file is required, its exports are cached by absolute path, and Node won't re-read or re-execute that file on subsequent requires, even if the file changes on disk, for the life of that process. That's why editing a route file doesn't reflect in a running Node server, the cached module keeps returning the old code.
Tools like nodemon don't clear the require cache selectively, they watch the filesystem for changes and, when something changes, kill the entire Node process and start a fresh one, which naturally gets a clean require cache. ts-node-dev works similarly but keeps the process alive longer and clears specific parts of the cache to restart faster. If you ever need to bust the cache manually, you can delete an entry from require.cache[require.resolve('./module')] before requiring it again, though this is rare in application code and mostly shows up in build tooling, test runners, or hot-reload implementations.
console.log writes plain text, synchronously when stdout is a TTY, with no structure, no log levels, and no easy way to filter by severity or attach consistent metadata like a request ID.
Structured loggers like pino write JSON, one object per line, with a level, timestamp, and whatever fields you attach, req.id, userId, duration, which lets log aggregation tools like Datadog, ELK, or CloudWatch parse and query logs instead of grepping raw text. Pino specifically is built to be fast, it does very little work in the main thread and can hand formatting off to a separate transport, which matters at high request volume where console.log's overhead adds up. The other reason to switch is log levels, with pino you set a level, info, debug, warn, error, per environment, verbose debug logs in development, quiet info-and-above in production, without touching code, whereas console.log calls are all-or-nothing, you either see them all or comment them all out.
An idempotent endpoint produces the same result no matter how many times you call it with the same input, calling it twice has the same effect as calling it once. GET, PUT, and DELETE are idempotent by definition in REST semantics, fetching, replacing, or deleting a resource twice should leave the system in the same state as doing it once. POST is not idempotent by default, calling it twice usually creates two records.
It matters for retries because networks are unreliable. A client sends a payment request, the server processes it successfully, but the response gets lost in transit, so the client, seeing a timeout, retries. If that endpoint isn't idempotent, the customer just got charged twice. The standard fix is an idempotency key, the client generates a unique key per logical operation and sends it in a header, the server checks whether it already processed a request with that key and, if so, returns the original result instead of doing the work again. Stripe's API is the textbook example of this pattern done well.
Hard questions
12process.nextTick() queues a callback to run immediately after the current operation finishes, before the event loop moves to its next phase and before any Promise microtasks. setImmediate() queues a callback for the check phase, which runs after the poll phase completes in that same loop iteration. In practice, nextTick callbacks always run before setImmediate callbacks when both are scheduled from the main module.
The one gotcha candidates miss: recursive process.nextTick() calls can starve the event loop entirely, since Node keeps draining the nextTick queue before doing anything else. Teams hit this in production, it isn't only an interview trick question.
Everything else stops. Since JavaScript execution is single-threaded, a long synchronous computation occupies that one thread completely, so no other request, no timer, no I/O callback can run until it finishes. A single slow endpoint can take down response times for an entire server, even requests that have nothing to do with it.
// This blocks every other request on the server
app.get('/report', (req, res) => {
let total = 0;
for (let i = 0; i < 5_000_000_000; i++) {
total += i;
}
res.json({ total });
});
// Better: offload to a worker thread and keep the event loop free
app.get('/report', async (req, res) => {
const total = await runInWorker('sum-loop', { limit: 5_000_000_000 });
res.json({ total });
});Interviewers ask this to see whether you actually understand the trade-off, not whether you can spot the obvious loop. The real answer involves recognizing CPU-bound work and moving it off the main thread, which is exactly what the worker threads section below covers.
Backpressure happens when a Readable stream produces data faster than a downstream Writable can consume it. Without handling it, unconsumed chunks pile up in memory, which defeats the entire point of streaming. Node's stream implementation handles most of this for you: write() returns false once the internal buffer passes its highWaterMark, and you're expected to pause reading until the Writable emits a 'drain' event. pipe() does this bookkeeping automatically, which is the main reason interviewers expect you to reach for pipe() over manually wiring 'data' event handlers.
This comes up in real incidents more than people expect, an unbounded proxy or file upload endpoint without proper backpressure handling is a classic way to OOM-kill a Node process under load.
An uncaught exception is a synchronous throw that no try/catch anywhere in the call stack handles; Node emits an 'uncaughtException' event and, if nothing listens for it, crashes the process. An unhandled rejection is a Promise that rejects with no .catch() or try/catch waiting for it. Node emits 'unhandledRejection' for this case, and since Node 15, the default behavior changed to crash the process on unhandled rejections too, matching how uncaught exceptions have always worked. Earlier versions only printed a deprecation warning, which let a lot of real bugs hide in production for years.
Listening for these events (process.on('uncaughtException', ...) and process.on('unhandledRejection', ...)) is useful for logging and a graceful shutdown, but it's not a substitute for actually handling the error where it happens. Restarting a process cleanly beats limping along in an unknown state.
cluster forks separate OS processes, each with isolated memory; workers can only communicate via IPC message passing (serialized data, no shared memory), and a crash in one worker doesn't take down the others. worker_threads run inside the same process and can share memory directly through SharedArrayBuffer, which makes communication much cheaper, but a fatal crash inside a worker thread can, in some cases, be harder to isolate cleanly than a separate process dying (Node.js worker_threads documentation).
Rule of thumb interviewers want to hear: worker_threads for CPU-intensive computation you want to parallelize within one process (image processing, hashing, big JSON parsing); cluster (or an external process manager) for scaling a stateless server across cores or machines. Neither one helps with I/O-bound work, Node's built-in async I/O already handles that more efficiently than either.
Move the computation into a worker_thread, pass it the input data, and resolve a Promise in the main thread when the worker posts its result back. This keeps the main event loop free to keep handling other requests while the CPU-heavy work runs on a separate thread.
// worker.js
const { parentPort, workerData } = require('worker_threads');
let total = 0;
for (let i = 0; i < workerData.limit; i++) total += i;
parentPort.postMessage(total);
// main.js
const { Worker } = require('worker_threads');
function runInWorker(limit) {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js', { workerData: { limit } });
worker.on('message', resolve);
worker.on('error', reject);
});
}Node's event loop itself is single-threaded, but a chunk of Node's async work, most filesystem operations, DNS lookups via dns.lookup(), some crypto functions like crypto.pbkdf2, and zlib compression, actually runs on a separate thread pool managed by libuv, not on the event loop thread. The default size of that pool is 4 threads, set by the UV_THREADPOOL_SIZE environment variable.
The practical problem shows up under concurrency. If your app fires off, say, 20 simultaneous fs.readFile calls, only 4 can actually be running at once, the rest queue up waiting for a free thread, even though the event loop itself is completely idle. This can look confusing in profiling, CPU usage is low, the event loop isn't blocked, but requests are still slow, because they're stuck waiting on a saturated, fixed-size thread pool. The fix is bumping UV_THREADPOOL_SIZE, up to 128, if your workload is genuinely bound by this pool, set before the process starts since libuv reads it once at initialization. It's also worth knowing that most raw TCP and HTTP networking doesn't use this pool at all, it's handled by the OS's async I/O directly through epoll, kqueue, or IOCP and goes straight through the event loop, so UV_THREADPOOL_SIZE won't help a network-bound workload, it's specifically for the subset of APIs libuv implements via the pool.
Event loop lag is the gap between when a timer or callback was scheduled to run and when it actually runs, and it climbs whenever something is hogging the event loop thread synchronously, a heavy JSON.parse on a huge payload, a poorly written regex with catastrophic backtracking, a synchronous crypto call, or just too much CPU-bound JS work per request. You detect it by scheduling something cheap on a short interval and measuring how much later than expected it actually fires. Tools like clinic.js or a homemade lag monitor using perf_hooks.monitorEventLoopDelay() do exactly this.
Under real load, the usual culprits are synchronous work that scales with request size, someone validating or transforming a large array or object per request without realizing that at 10 requests per second with a 50ms transform, the loop is already saturated. Mitigation is either moving that work off the main thread, worker_threads for CPU-bound work, or a queue and background job for anything that doesn't need to block the response, or fixing the algorithmic complexity causing it in the first place. A regex catastrophic backtracking bug specifically can take an event loop from fine to completely frozen on a single malicious or oversized input, which makes it a real production incident, not just a performance nuisance.
Prototype pollution happens when attacker-controlled input lets you write to Object.prototype itself, usually through a recursive merge or deep-clone function that doesn't guard against keys like __proto__, constructor, or prototype. If a merge function blindly does target[key] = source[key] recursively and source has a __proto__ key with attacker-controlled properties, you can end up mutating Object.prototype, which means every plain object in the entire process now inherits those polluted properties, not just the one object being merged.
This became a real, widely felt issue with lodash's merge, mergeWith, and defaultsDeep functions before they were patched, where passing crafted JSON through those functions from user input could pollute the global prototype. The impact ranges from denial of service, polluting a property that later code checks, causing crashes or logic errors across unrelated parts of the app, to privilege escalation in some frameworks, if server code later does something like if (user.isAdmin) and isAdmin got polluted onto Object.prototype as true, every object without its own isAdmin property suddenly evaluates as an admin. Defenses: use Object.create(null) for objects meant to hold arbitrary user-supplied keys, use Map instead of plain objects for user-controlled key-value data, keep dependencies patched, and if you must write a merge utility, explicitly reject __proto__, constructor, and prototype as keys.
dns.lookup() uses the operating system's resolver via getaddrinfo, which respects /etc/hosts, NSS config, and OS-level DNS caching, and by default dispatches through libuv's thread pool since getaddrinfo is a blocking system call. dns.resolve() and its variants, resolve4, resolve6, resolveCname, go straight to a DNS server over the network using c-ares, bypassing the OS resolver entirely, ignoring /etc/hosts, and running on the event loop's own async I/O rather than the thread pool.
A production bug this difference has caused more than once: an app uses dns.lookup() for outbound calls to a service behind a load balancer with multiple A records, but the OS resolver caches or picks a single address more stickily than expected, so all your traffic quietly pins to one backend IP instead of spreading across the pool, and if that one IP goes down, the app can't reach the service even though healthy IPs exist in the DNS record. Another version: dns.lookup() under high concurrency saturates the same libuv thread pool used for fs and crypto, since getaddrinfo is one of the pooled operations, so a spike in outbound connections creates DNS resolution latency that looks like a random slowdown until you trace it back to thread pool contention. Switching to dns.resolve4() avoids the thread pool entirely and gives you the actual DNS answer rather than an OS-mediated one, though you lose /etc/hosts support, which matters in some containerized or local dev setups.
PM2's cluster mode runs multiple Node processes, typically one per CPU core, behind PM2's built-in load balancer, and pm2 reload is built specifically for zero-downtime updates. Instead of killing all workers and starting new ones, which is what pm2 restart does, with a brief gap, reload replaces workers one at a time, starting a new process with the updated code, waiting for it to signal it's ready, then killing the old one and moving to the next, so there's always at least one worker serving traffic.
For this to actually be zero-downtime rather than just mostly fine, your app needs to cooperate. It should handle SIGINT and SIGTERM gracefully, draining in-flight requests rather than dying immediately, since PM2 sends a shutdown signal to the old worker before killing it, and if you're using PM2's wait_ready option, it should explicitly call process.send('ready') once the server has actually started listening, otherwise PM2 might route traffic to a worker that hasn't finished booting. Beyond single-machine PM2 reloads, the same idea scales to blue-green deployment at the infrastructure level. You deploy the new version to a separate set of instances entirely, wait for health checks to pass, then flip the load balancer to point at the new set and drain the old one, which additionally protects you from bad deploys since you can flip back instantly if the new version is broken, something in-place rolling reloads can't give you as cleanly.
The first thing to establish is whether it's an actual leak, memory climbs steadily and never comes back down even under low load, or just legitimate high usage under peak load that a bigger --max-old-space-size would tolerate. You confirm a real leak by watching process.memoryUsage().heapUsed over time, or RSS from the OS side, and seeing it climb monotonically across multiple GC cycles rather than sawtooth up and down as you'd expect from healthy allocation and collection.
Once you know it's a leak, the standard approach is taking heap snapshots at two points in time under similar load, with node --inspect and Chrome DevTools' Memory tab, or programmatically with packages like heapdump or v8-profiler-next, then using the comparison view between the two snapshots to see what object types grew and didn't shrink back. The usual suspects there: an array or Map being pushed to on every request and never trimmed, event listeners re-registered on a long-lived EventEmitter without ever being removed, each holding a closure over request-scoped data, or a cache with no eviction policy.
Since this often only reproduces under production load, the practical trick is reproducing it with a load test tool like autocannon or k6 against a staging environment with the inspector attached, rather than trying to attach a profiler to live production traffic, which adds its own overhead and risk.
How to prepare
Most Node.js interview questions prep guides send you straight to LeetCode-style practice. Read the actual Node.js docs for the event loop and streams sections first. Skipping the primary source shows the moment an interviewer asks a follow-up that isn't in any prep guide.
Candidates who run a Node-focused mock interview on LastRoundAI most often stumble on the same follow-up question pattern: they can name the event loop correctly on the first ask, then can't trace what actually happens when a synchronous loop shows up inside a request handler. That single follow-up separates a memorized answer from real understanding more than any other question in this list, and it's worth over-preparing specifically rather than assuming the first-level answer is enough.
Beyond that: build one small project end to end (an Express API with a file upload endpoint that streams to disk, not memory) instead of only reading about streams in the abstract. Practice explaining your reasoning out loud instead of only arriving at the right answer in your head, since almost every question above has a "why" follow-up waiting behind the first correct response.
Practicing this out loud, under some time pressure, with follow-ups you can't predict, is exactly what LastRoundAI's mock interviews are built for: role-specific question sets, follow-up probing similar to what's above, and feedback on where your explanation broke down rather than just whether the final answer was right. The free plan includes 15 credits a month that reset monthly (any extra credits you purchase separately don't expire). Starter is $19/month if you want more runway. If you're applying broadly for backend or Node roles at the same time, Auto-Apply ranks job matches by resume fit, so postings that mention Node, Express, or backend work naturally surface higher for a backend resume, and nothing gets submitted until you approve it in the review queue. There's no native mobile app yet, just the desktop app and a browser version that works fine on a phone. Questions go to contact@lastroundai.com.
Whether the cluster module deserves its current amount of airtime across Node.js interview questions is a separate argument. Either way, know it, know worker_threads, and know exactly which follow-up question is waiting behind your first answer.

