Node.js interviews test whether you understand the single-threaded, non-blocking model and its consequences. Expect questions on the event loop phases, the difference between process.nextTick and setImmediate, streams and backpressure, error handling across callbacks, promises and EventEmitters, and how to scale across CPU cores. Employers also probe production experience: memory leaks, blocking the event loop, and securing an API. The questions below cover the runtime, the Express layer and the operational concerns.
Behavioural Questions
1. Tell me about a Node.js service you built or maintained. What was it responsible for and what was hard about it?
Note: Give scale numbers. "An API" tells the interviewer nothing; "an order service handling about 400 requests a second at peak" tells them what problems you have actually met.
Cover four things in order:
- What it did and who depended on it. One sentence. Was it user-facing, or a background worker other services relied on?
- The shape of the load. Steady traffic, spiky traffic, long-running jobs, or lots of small I/O. This determines every interesting decision that follows.
- The genuinely hard part. Good candidates: keeping the event loop unblocked when a CPU-heavy task appeared, handling a downstream service that became slow rather than failing outright, memory growth you had to trace, or making retries safe when the same message could arrive twice.
- What you would do differently. This is the part that separates a senior answer from a junior one.
2. Describe a production incident you were involved in. How did you diagnose it and what did you change afterwards?
Tell it as a timeline, and resist the urge to make yourself the hero.
- Detection. What alerted you — error rate, latency, memory, or a customer? If it was a customer, say so; that itself was a finding.
- Triage. What you did to stop the bleeding before you understood the cause. Rolling back, scaling out, disabling a feature flag, or shedding load are all legitimate first moves.
- Diagnosis. The evidence you used: structured logs with a correlation id, a heap snapshot, event loop lag metrics, or a flame graph from
--prof. - The fix, and then the real fix. The immediate patch, and the systemic change — a timeout that was missing, a circuit breaker, a bounded queue, or a health check that actually checked something.
Note: Mention the blameless postmortem if your team ran one. Interviewers read that as maturity, and it lets you describe a failure without it sounding like someone was at fault.
3. How do you decide when a Node.js service should be split apart, or when separate services should be merged?
Answer with criteria rather than a preference for microservices or monoliths — strong candidates are known for resisting both dogmas.
Reasons to split:
- Different scaling profiles. A video transcoder and a login endpoint do not want the same instance count or the same machine.
- Different failure requirements. If the reporting dashboard falling over must never take payments down, that boundary is real.
- Different teams and release cadences. If two teams block each other on every deploy, the seam is organisational as much as technical.
Reasons to merge, or to never split:
- The two services always change together, so every feature becomes a two-repository, two-deploy exercise.
- They share a database, in which case they are one service wearing two hats.
- You cannot afford the operational cost — tracing, deploy pipelines, and on-call all multiply.
Note: Saying you would start with a well-structured single service and split when a specific pain appears is a defensible and increasingly common position.
4. How do you approach onboarding to an unfamiliar Node.js codebase?
Describe a repeatable method, because you will be doing exactly this in the first week of the job.
- Get it running first. Before reading anything, install, run, and hit one endpoint. What is missing from the README is itself the first useful contribution.
- Follow one request end to end. Pick a real route and trace it through routing, middleware, service layer, and database. One vertical slice teaches you more than reading every folder.
- Read the tests and the package.json. Tests document intent, and the dependency list tells you the architecture — an ORM, a queue library, and a validation library between them describe the shape of the system.
- Find out how it is deployed and observed. Where the logs go, what the alerts are, how a rollback works.
- Start with a small real change. A bug fix teaches you the workflow; reading teaches you the map.
Note: Mention writing down what confused you and turning it into documentation. It is a genuinely valued habit and easy to demonstrate.
5. How do you balance shipping quickly against writing code that will be maintainable?
The honest answer is that it depends on how reversible the decision is, and saying so directly is stronger than claiming you always do both.
Where speed is fine: anything cheap to change later — internal tooling, a feature behind a flag, a first version of an endpoint whose shape nobody has agreed yet. Here the risk of over-designing for requirements that never arrive is higher than the risk of rework.
Where it is not: decisions that are expensive to reverse. Database schemas, public API contracts, message formats, and anything that touches money or authentication. These deserve the extra day.
How to keep the trade-off visible:
- Write the shortcut down where it will be found — a TODO with a ticket number, not a comment nobody reads.
- Keep tests on the parts that are hard to verify by hand, even when you are moving fast.
- Raise it as a cost, not a complaint: "we can ship Thursday, and it will cost us about two days in January."
6. Tell me about a time you improved the latency or throughput of a Node.js API. How did you find the bottleneck and prove the fix worked?
Interviewers want evidence that you measure before you change anything. Structure the answer as baseline, diagnosis, fix, and verified result.
- Set the scene with numbers. “Our order-lookup endpoint had a p95 of 1.8 seconds under peak traffic, and the mobile team was seeing timeouts.” A percentile beats an average, because averages hide the slow tail users actually feel.
- Explain how you located the bottleneck. Mention real tools: APM traces showing where time went, a CPU profile or flame graph taken with
--cpu-profor Clinic, event loop delay metrics fromperf_hooks.monitorEventLoopDelay, or the slow query log on the database. Good candidates say what they ruled out, not just what they found. - Describe the fix and why you chose it. Typical real causes: an N+1 query pattern, a missing index, synchronous JSON parsing of a huge payload, sequential
awaits that could run withPromise.all, no HTTP keep-alive to a downstream service, or a connection pool that was too small. - Prove it. A load test with autocannon or k6 before and after, then production dashboards over a week. “p95 dropped to 240 ms and CPU per request fell by a third.”
- Close with what you changed long term. A performance budget in CI, an alert on event loop lag, or a code review checklist item.
Note: Avoid stories where you rewrote everything in a new framework. The strongest answers show a small, targeted change backed by a profile and a before-and-after measurement.
7. Describe a time you had to deal with a vulnerable or abandoned npm dependency in production code. What did you do?
This question tests judgement about risk, not just whether you can run npm audit. Walk through triage, decision, execution and prevention.
- How you found out. A Dependabot or Snyk alert, an
npm auditfailure in CI, or a security advisory on GitHub. Name the class of issue, for example prototype pollution in a transitive dependency of a form parser. - Triage honestly. Was the vulnerable code path actually reachable in our app? Was it a dev-only dependency? Many audit findings are noise, and saying so shows maturity, but you still document the decision.
- Choose the fix. Options in rough order of preference: upgrade to a patched version; use an
overridesentry inpackage.jsonto force a patched transitive version; replace the package with a maintained alternative or a built-in (nativefetchreplacing an old HTTP client); or, as a last resort, vendor and patch it with patch-package. - Execute safely. Upgrade on a branch, run the full test suite, check the changelog for breaking changes, and release behind normal deployment checks.
- Prevent a repeat. Automated dependency updates, a policy of checking maintenance activity before adopting a package, and fewer dependencies overall.
Note: If the package was abandoned, mention how you assessed alternatives: weekly downloads, open issues, last release date, and whether a built-in Node API could replace it entirely.
8. Tell me about a disagreement you had with the front-end team over an API contract. How did you resolve it?
The interviewer is checking whether you treat an API as a product with customers. Use a STAR structure and show that you argued from the consumer's needs and from data, not from ownership.
- Situation. “The web team wanted the dashboard endpoint to return everything in one nested response. I wanted separate resource endpoints because the nested one would couple our schema to one screen.”
- Understand the real need. Ask why. Often the front end is fighting latency from many round trips, or awkward pagination, or inconsistent error shapes. Once you know the underlying problem, a better solution usually appears.
- Find options together. A composed endpoint for that screen backed by reusable services, field selection with a
fieldsquery parameter, a BFF (backend for frontend) layer, or GraphQL if the pattern repeats across screens. - Make it concrete. Write the contract down as an OpenAPI spec, agree on error format, pagination and versioning, and generate types or a mock server so both teams can work in parallel.
- Result. “We shipped a composed endpoint that cut page load requests from nine to two, and the OpenAPI spec became the default for new endpoints.”
Note: Never describe the other team as wrong. Show that the disagreement produced a better contract and a process, such as contract tests or spec reviews, that prevented the next one.
9. Describe a time a downstream service your Node.js application depended on was unreliable. How did you design around it?
This checks whether you think about failure as a normal condition. A strong answer names the failure mode, the protective patterns you added, and the measurable effect.
- The failure. “A third-party payment status API would hang for 30 seconds at peak, which tied up our sockets and made our own API slow for every user, not just payment pages.”
- Timeouts first. Every outbound call got an explicit deadline, for example
fetch(url, { signal: AbortSignal.timeout(2000) }). Without a timeout, one slow dependency exhausts your connection pool. - Retries with care. Only for idempotent operations, with exponential backoff and jitter, and a small maximum. Blind retries turn a partial outage into a self-inflicted denial of service.
- Circuit breaker. After a threshold of failures, fail fast for a cooling period instead of waiting on a service that is clearly down. Libraries such as opossum implement this.
- Graceful degradation. Serve a cached status, queue the work for later, or show “pending” rather than an error page.
- Observability. Metrics on dependency latency and error rate, and alerts that point at the dependency rather than at us.
Finish with the outcome, such as “our p99 stayed flat during their next two outages”, and what you would reuse elsewhere.
Note: Mention bulkheading if you can: give each dependency its own connection pool or concurrency limit so one bad dependency cannot starve the others.
10. Tell me about a time you shipped a breaking API or database schema change without downtime. How did you plan it?
The interviewer wants to hear that you know breaking changes are done in several backward-compatible steps, not one big deploy. The expand and contract pattern is the vocabulary to use.
- Context. “We needed to split a
namecolumn intofirst_nameandlast_nameon a table read by three services, while serving traffic 24x7.” - Expand. Add the new columns as nullable. Old code keeps working because nothing it uses has changed.
- Dual write. Deploy code that writes to both old and new columns, so new rows are correct.
- Backfill. Migrate existing rows in small batches with a throttled script, so the database is not locked or overloaded.
- Switch reads. Move readers to the new columns, one service at a time, with a feature flag so you can roll back instantly.
- Contract. Only once nothing reads the old column, and after a safety window, drop it.
For API changes the same idea applies: add the new field or a new version, keep the old one working, communicate a deprecation date, watch access logs until usage of the old shape reaches zero, then remove it.
Note: Mention rollback at every step. The point of the multi-step approach is that each deploy on its own is safe to reverse, which is what makes zero downtime realistic.
Technical Questions
11. How does the Node.js event loop work, and what are its phases?
Node runs your JavaScript on one thread and hands I/O to the operating system through libuv. The event loop is what decides which completed callback runs next, and it cycles through fixed phases:
- timers — callbacks scheduled by
setTimeoutandsetIntervalwhose time has come. - pending callbacks — some system-level callbacks deferred from the previous cycle.
- idle, prepare — internal use only.
- poll — the important one. Node retrieves new I/O events and runs their callbacks, and will block here waiting for work if there is nothing else to do.
- check —
setImmediatecallbacks. - close callbacks —
'close'events, such as a socket being destroyed.
Between every phase, and between individual callbacks, Node drains two queues completely: process.nextTick first, then the promise microtask queue.
Note: The consequence worth stating is that any CPU-heavy synchronous work blocks the entire loop — no other request is served while it runs. That is why hashing, image processing, or large JSON parsing belong in a worker thread or a separate process.
12. What is the difference between process.nextTick, setImmediate and setTimeout with zero delay?
They are three different queues with three different priorities, and the distinction comes up constantly in Node interviews.
process.nextTick— not part of the event loop at all. Its queue is drained after the current operation completes and before the loop continues to the next phase. It has the highest priority, and it runs before promise microtasks.setImmediate— runs in the check phase, which is immediately after the poll phase. If you want to yield to I/O and then run, this is the correct choice.setTimeout(fn, 0)— runs in the timers phase. The zero is treated as one millisecond, so it is not really immediate.
The subtlety interviewers look for: at the top level of a script, the order of setTimeout(fn, 0) and setImmediate(fn) is non-deterministic, because it depends on how long the process took to start. Inside an I/O callback, however, setImmediate always fires first, because the loop is already past the timers phase and heading into check.
Note: Recursive process.nextTick calls will starve the event loop entirely. setImmediate will not.
13. What are streams in Node.js, what types exist, and why would you use one instead of reading a whole file?
A stream processes data in chunks as it arrives, instead of loading everything into memory first. Reading a two gigabyte file with fs.readFile needs two gigabytes of RAM; reading it as a stream needs only the size of one chunk.
The four types:
- Readable — a source you consume from, such as
fs.createReadStreamor an incoming HTTP request. - Writable — a destination, such as
fs.createWriteStreamor an HTTP response. - Duplex — both, with the two sides independent. A TCP socket is the standard example.
- Transform — a duplex stream where the output is a function of the input, such as
zlib.createGzip().
Backpressure is the reason streams are worth understanding. If the destination is slower than the source, data piles up in memory. pipe, and better still pipeline, handle this for you by pausing the source when the destination's buffer is full.
const { pipeline } = require('stream/promises');
await pipeline(
fs.createReadStream('in.csv'),
zlib.createGzip(),
fs.createWriteStream('out.csv.gz')
);Note: Prefer pipeline over pipe. pipe does not forward errors or clean up the remaining streams when one fails, which leaks file descriptors.
14. How do you handle errors in Node.js, including in async code and at the process level?
Node has several error channels and you need to cover all of them.
- Synchronous code —
try/catch. - Promises and async/await —
try/catcharound theawait, or.catch()on the chain. In Express 4 an async route handler that throws is not caught by the framework, so you need a wrapper that forwards the rejection tonext(err); Express 5 handles it natively. - Callbacks — the error-first convention,
(err, result). Checkerrbefore anything else and return early. - EventEmitters — listen for the
'error'event. An unhandled'error'event throws and crashes the process, which is deliberate.
At the process level: attach handlers for uncaughtException and unhandledRejection, but use them only to log and then exit cleanly — not to keep running. After an uncaught exception the process is in an unknown state, and continuing risks corrupting data.
Note: Distinguish operational errors, such as a failed network call, which you handle and retry, from programmer errors such as a TypeError, which you should let crash so a supervisor restarts the process.
15. What is the difference between CommonJS and ES modules in Node.js?
They are two module systems with different loading semantics, and mixing them is where the pain lives.
- Syntax. CommonJS uses
require()andmodule.exports; ES modules useimportandexport. - Timing.
requireis synchronous and can be called anywhere, including conditionally inside a function.importis static and hoisted — the module graph is resolved before any code runs, which is what makes tree-shaking possible. For a conditional load you need dynamicimport(), which returns a promise. - Bindings. CommonJS gives you a copy of the exported value at the time of the require. ES modules give you a live binding, so if the exporting module reassigns the variable later, importers see the new value.
- Enabling them. Either
"type": "module"in package.json, or the.mjsextension. Under ESM there is no__dirname,__filename, orrequire; the equivalents come fromimport.meta.url.
Note: An ES module can import a CommonJS module, but CommonJS cannot require an ES module — because require is synchronous and ESM resolution is asynchronous. That asymmetry is the single most common migration problem.
16. How does middleware work in Express, and how would you write your own?
Express middleware is a function with the signature (req, res, next). Express keeps an ordered stack of them and passes the request down that stack; each one can read or modify req and res, then either end the response or call next() to hand control on.
function requestTimer(req, res, next) {
const start = process.hrtime.bigint();
res.on('finish', () => {
const ms = Number(process.hrtime.bigint() - start) / 1e6;
console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${ms.toFixed(1)}ms`);
});
next();
}
app.use(requestTimer);The rules that matter:
- Order is everything.
app.useregisters in sequence, so a body parser must come before the route that readsreq.body, and an auth check must come before the handler it protects. - Forgetting
next()hangs the request — no error, just a client waiting until it times out. - Error middleware takes four arguments,
(err, req, res, next). Express identifies it by arity, and it must be registered last.
17. How would you scale a Node.js application across CPU cores, and what is the cluster module?
A single Node process uses one core for JavaScript, so on an eight-core machine you are using an eighth of the hardware. There are three answers, and the best one depends on where you deploy.
- The cluster module forks one worker process per core. The primary process holds the listening socket and distributes incoming connections; the workers each run your full application. Because they are separate processes they share nothing, so in-memory state and sessions must move to Redis or a database.
- A process manager such as PM2 in cluster mode does the same thing with restarts, zero-downtime reloads, and monitoring included.
- Container replicas. In Kubernetes or ECS the usual approach is one Node process per container and let the orchestrator run several containers — it gives you the same parallelism plus scheduling and health checks.
Worker threads are a different tool. Cluster scales I/O-bound work across processes; worker_threads moves a CPU-bound task off the main thread within one process, and can share memory through SharedArrayBuffer.
Note: Say explicitly that clustering does not fix a blocked event loop. If one request spends 500ms hashing, clustering just gives you eight event loops to block.
18. What are some common security issues in a Node.js API and how do you defend against them?
Work through the layers rather than listing tools.
- Injection. Never build SQL by string concatenation — use parameterised queries or an ORM. For NoSQL, reject object-valued query parameters, which is how
{ $gt: '' }becomes an authentication bypass. - Input validation. Validate and coerce every request body and query parameter against a schema at the edge, with something like Zod or Joi. Anything unvalidated eventually reaches a database or a shell.
- Authentication and sessions. Hash passwords with bcrypt or argon2, never a plain digest. Keep JWT lifetimes short, use refresh tokens, and store them in
httpOnly,secure,sameSitecookies rather than localStorage. - Rate limiting and payload limits. Cap request body size and rate limit authentication endpoints specifically, or you are shipping a credential-stuffing target.
- Headers and transport. Use helmet for sensible defaults, enforce HTTPS, and configure CORS to a real allowlist rather than
*. - Dependencies.
npm auditin CI, lockfiles committed, and as few transitive dependencies as you can manage. - Secrets. In environment variables or a secret manager, never in the repository.
19. How do you debug a memory leak in a Node.js process?
Approach it as measure, capture, compare, fix.
- Confirm it is real. Track
process.memoryUsage().heapUsedover hours. Node's heap grows and then plateaus normally; a leak keeps climbing and does not come back down after garbage collection. Run with--expose-gcand force a collection to be sure. - Capture heap snapshots. Start with
--inspectand take a snapshot in Chrome DevTools, or usev8.writeHeapSnapshot(). Take one early, apply load, take another. - Compare them. The comparison view sorts objects by what was allocated and never freed. Look at retained size and follow the retainer path to whatever is still holding the reference.
The usual culprits:
- A module-level array or Map that only ever grows — caches without eviction are the number one cause.
- Event listeners added per request but never removed, which is why Node warns at eleven listeners.
- Timers that are never cleared.
- Closures capturing a large object and stored somewhere long-lived.
Note: Mention WeakMap and WeakRef as the structural fix when you need to associate data with an object without keeping it alive.
20. What is the difference between blocking and non-blocking code in Node.js, and how do you keep the event loop responsive?
Blocking means the JavaScript thread cannot do anything else until the operation finishes. Non-blocking means the work is handed off and a callback runs when it completes. Because Node serves every request on one thread, one blocking call delays every other user.
What blocks:
- The synchronous
fsmethods —readFileSync,writeFileSyncand friends. - CPU-heavy work: hashing with a high cost factor, image resizing, large
JSON.parse, sorting a huge array, complex regular expressions on long strings. child_process.execSync.
How to keep the loop free:
- Use the promise-based
fsAPI for anything on a request path. Sync calls are acceptable only at startup. - Move CPU-bound work to
worker_threads, or to a queue consumed by a separate process. - Break long loops into chunks that yield with
setImmediate. - Stream large payloads rather than buffering them.
Note: Say how you would detect it. Measuring event loop lag — with perf_hooks monitorEventLoopDelay or a simple timer drift check — and alerting on it is what turns this from theory into operations.
21. What is libuv, and which Node.js operations run on its thread pool rather than on the event loop thread?
libuv is the C library that gives Node.js its event loop and its cross-platform asynchronous I/O. It wraps the operating system's readiness mechanisms (epoll on Linux, kqueue on macOS, IOCP on Windows) and also provides a small thread pool for work the OS cannot do asynchronously.
Handled by the OS without the thread pool: network sockets (TCP, UDP, HTTP), pipes, and timers. These are truly non-blocking at the OS level, so one thread can watch thousands of connections.
Handled by the thread pool:
- Most
fsoperations, because file I/O is blocking on most platforms. dns.lookup(), which uses the system resolver (getaddrinfo). By contrastdns.resolve*()uses c-ares over the network and does not use the pool.- CPU-heavy
cryptofunctions such aspbkdf2,scrypt,randomBytesand key generation, when called asynchronously. zlibasynchronous compression.
The pool has 4 threads by default, configurable with the UV_THREADPOOL_SIZE environment variable, which must be set before the pool is first used.
// Run with UV_THREADPOOL_SIZE=8 node app.js
const crypto = require('node:crypto');
for (let i = 0; i < 8; i++) {
crypto.pbkdf2('pw', 'salt', 1e5, 64, 'sha512', () => console.log('done', i));
}Run it without the variable and, with the default pool of four, the eight hashes finish in two visible batches; with a pool of eight they finish together, provided the machine has the cores.
Note: A classic production issue is slow DNS or heavy file access starving the pool, so unrelated crypto or fs calls queue up. Raising the pool size helps only if the machine has cores to spare.
22. How does backpressure work in Node.js streams in practice? Explain highWaterMark, a false return from write(), and the drain event.
Backpressure is the mechanism that stops a fast producer from overwhelming a slow consumer. Without it, data piles up in memory until the process crashes.
- highWaterMark is the buffer threshold for a stream: 16 KiB by default for byte streams (64 KiB for
fsread streams), or 16 objects in object mode. It is a hint, not a hard cap. writable.write(chunk)returnsfalsewhen the internal buffer has reached or passed highWaterMark. The chunk is still accepted, but the stream is asking you to stop.- The
drainevent fires once the buffer has emptied enough to accept more. That is your signal to resume.
const { once } = require('node:events');
async function writeMany(ws, rows) {
for (const row of rows) {
if (!ws.write(row + '\n')) {
await once(ws, 'drain'); // pause until the consumer catches up
}
}
ws.end();
}If you ignore the return value and keep writing, the data is buffered in memory without limit. On the readable side the equivalent is that push() returns false, and _read() will not be called again until the consumer wants more.
In real code you rarely manage this by hand. stream.pipeline(), readable.pipe() and for await over a readable all respect backpressure automatically. pipeline is preferred because it also propagates errors and destroys every stream on failure.
Note: A common interview trap is an HTTP handler that reads a large file with a data listener and calls res.write in a loop. On a slow client that buffers the whole file in memory; piping or pipeline fixes it.
23. How do you write a custom Transform stream in Node.js, and how can async generators be used inside stream.pipeline?
A Transform stream is a Duplex whose output is computed from its input: compression, parsing, encryption, line splitting. You implement transform(chunk, encoding, callback) and optionally flush(callback) for anything left over at the end.
const { Transform, pipeline } = require('node:stream');
class LineSplitter extends Transform {
constructor() { super({ readableObjectMode: true }); this.rest = ''; }
_transform(chunk, enc, cb) {
const lines = (this.rest + chunk).split('\n');
this.rest = lines.pop(); // keep the partial last line
for (const l of lines) this.push(l);
cb();
}
_flush(cb) { if (this.rest) this.push(this.rest); cb(); }
}Key points: call the callback exactly once per chunk (passing an error as the first argument fails the stream), use push() to emit output, and set object mode when you emit non-Buffer values.
Async generators are often simpler. stream.pipeline from node:stream/promises accepts an async generator function as a stage: it receives the previous stage as an async iterable and yields the output.
const { pipeline } = require('node:stream/promises');
const fs = require('node:fs');
const zlib = require('node:zlib');
await pipeline(
fs.createReadStream('events.log'),
async function* (source) {
for await (const chunk of source) {
yield chunk.toString().toUpperCase();
}
},
zlib.createGzip(),
fs.createWriteStream('events.log.gz')
);Backpressure is still respected, because the generator only pulls the next chunk when the downstream stage is ready, and any thrown error rejects the whole pipeline and destroys all streams.
Note: Watch for chunk boundaries. A chunk can end in the middle of a line or even in the middle of a multi-byte UTF-8 character, which is why real parsers buffer the remainder or use a StringDecoder.
24. What is a Buffer in practice? Explain Buffer.alloc versus Buffer.allocUnsafe, encodings, and length versus byteLength.
A Buffer is a fixed-size chunk of raw bytes living outside the V8 heap, and it is a subclass of Uint8Array. Node uses it for everything binary: file contents, network packets, hashes, image data.
Creating buffers
Buffer.alloc(size)returns zero-filled memory. Safe, slightly slower.Buffer.allocUnsafe(size)returns memory that is not initialised and may contain old data from other parts of the process, possibly secrets. It is faster, but only use it when you will overwrite every byte immediately.Buffer.from(value, encoding)copies from a string, array or ArrayBuffer. The oldnew Buffer()constructor is deprecated for exactly this safety reason.
Encodings matter whenever you convert between strings and bytes: utf8 (default), base64, base64url, hex, latin1 and utf16le.
const b = Buffer.from('नमस्ते', 'utf8');
'नमस्ते'.length; // 6 UTF-16 code units
Buffer.byteLength('नमस्ते'); // 18 bytes
b.toString('base64'); // encode for transport
b.subarray(0, 3); // a view, no copylength vs byteLength: string.length counts UTF-16 code units, while Buffer.byteLength(string) counts bytes in the chosen encoding. Use byte length for the Content-Length header, size limits and database column checks, otherwise non-ASCII text such as Hindi or emoji will break your maths.
Also note that subarray (and the older slice) returns a view sharing the same memory, so modifying it changes the original.
Note: When comparing secrets such as HMAC signatures held in buffers, use crypto.timingSafeEqual rather than equality checks, to avoid timing attacks.
25. When would you use worker_threads, child_process or the cluster module in Node.js, and how do they differ?
All three give you more than one thread of JavaScript execution, but they solve different problems.
| Tool | What it creates | Best for |
|---|---|---|
worker_threads | Threads in the same process, each with its own V8 isolate and event loop | CPU-heavy JavaScript: image processing, parsing, hashing, report generation |
child_process | A separate OS process | Running other programs (ffmpeg, git, Python) or isolating risky code |
cluster | Several copies of your Node server process sharing one listening port | Using all CPU cores for an HTTP server |
Memory and communication. Workers communicate with postMessage using the structured clone algorithm, can transfer an ArrayBuffer without copying, and can share memory with SharedArrayBuffer plus Atomics. Child processes and cluster workers share nothing; they talk over IPC channels or stdio, which is slower but gives full isolation, so a crash in one does not take down the others.
// main.js
const { Worker } = require('node:worker_threads');
const w = new Worker('./resize.js', { workerData: { file: 'a.png' } });
w.on('message', (r) => console.log('done', r));
w.on('error', console.error);Practical guidance: do not spawn a worker per request, because start-up costs tens of milliseconds and memory. Use a pool such as Piscina. For scaling a web server in containers, many teams skip cluster entirely and run one process per container, letting the orchestrator handle replicas.
Note: Workers do not make I/O faster. Network and file I/O are already asynchronous, so workers only help when the main thread is busy running JavaScript.
26. How do you implement graceful shutdown in a Node.js HTTP service when it receives SIGTERM?
Orchestrators such as Kubernetes, ECS and systemd stop a process by sending SIGTERM, waiting for a grace period (30 seconds by default in Kubernetes), then sending SIGKILL. Graceful shutdown means using that window to finish in-flight work instead of dropping requests.
The sequence:
- Fail readiness. Make the readiness probe return 503 so the load balancer stops sending new traffic. A short delay here helps, since endpoint updates propagate asynchronously.
- Stop accepting connections with
server.close(). It stops new connections, but waits for existing ones. - Handle keep-alive. Idle keep-alive sockets would keep the server open, so call
server.closeIdleConnections()(Node 18.2 and later) and let active requests finish. - Drain other work: stop queue consumers, finish current jobs, flush logs and metrics.
- Close resources: database pools, Redis clients, message brokers.
- Exit, with a hard timeout as a safety net.
process.on('SIGTERM', async () => {
ready = false; // readiness probe now fails
setTimeout(() => process.exit(1), 25000).unref(); // safety net
server.close(async () => {
await db.end();
await redis.quit();
process.exit(0);
});
server.closeIdleConnections();
});Also handle SIGINT for local Ctrl+C, and make sure Node is actually receiving the signal: in Docker, if a shell script is PID 1 it may not forward signals, so use exec node app.js or an init such as tini.
Note: Running npm start as the container entry point is a common reason signals never reach Node. Invoke node directly in production images.
27. What are exit codes and process signals in Node.js, and why is setting process.exitCode usually better than calling process.exit()?
An exit code tells whatever started the process (a shell, CI, Docker, Kubernetes) whether it succeeded. 0 means success and anything else means failure. Node uses 1 for an uncaught exception and a few others internally, and a process killed by a signal is conventionally reported as 128 plus the signal number, so SIGKILL shows up as 137 and SIGTERM as 143.
Signals are OS-level notifications. You can listen for most of them:
SIGTERM: polite request to stop, sent by orchestrators.SIGINT: Ctrl+C in a terminal.SIGHUP: terminal closed, sometimes used to reload config.SIGKILLandSIGSTOPcannot be caught at all.
Once you attach a listener for SIGTERM or SIGINT, Node no longer exits by default, so your handler must finish the job.
exit() vs exitCode. process.exit(n) ends the process immediately, even if there are pending writes to stdout, unflushed logs or in-flight requests. Asynchronous work is simply abandoned. Setting process.exitCode = n instead records the code and lets Node exit naturally once the event loop is empty, so buffered output is flushed.
async function main() {
const ok = await runMigrations();
if (!ok) process.exitCode = 1; // exit later, after logs flush
}
main().catch((err) => { console.error(err); process.exitCode = 1; });Use process.exit() only when you must stop despite open handles, for example as a timeout fallback during shutdown, or after an uncaughtException where the process state can no longer be trusted.
Note: In a CLI tool, a missing exit code is a real bug: CI pipelines treat the step as successful even though the script printed an error.
28. How does EventEmitter work internally, and what are the gotchas around the error event, listener leaks and once()?
EventEmitter is a simple publish and subscribe object: it keeps a map from event name to an array of listener functions. emit() calls those listeners synchronously, in registration order, and returns whether any listener existed. Streams, HTTP servers and sockets are all emitters.
Gotchas worth knowing:
- Synchronous emit. A slow listener blocks the emitter and everything after it. An exception thrown in a listener propagates out of
emit(). - The error event is special. If you
emit('error', err)and nobody is listening, Node throws the error, which usually crashes the process. Always attach an error listener to streams, sockets and clients. - MaxListenersExceededWarning. By default, more than 10 listeners for one event prints a warning. It is not a hard limit; it is a hint that you may be adding a listener per request and never removing it, a classic memory leak. Fix the leak rather than raising the limit with
setMaxListeners. - once().
emitter.once(name, fn)removes itself after the first call. The staticevents.once(emitter, name)returns a promise, and rejects iferrorfires first. - Removing listeners requires the same function reference, so anonymous inline functions cannot be removed.
const { EventEmitter, once, on } = require('node:events');
const bus = new EventEmitter();
bus.on('error', (err) => log.error(err)); // never skip this
const [order] = await once(bus, 'order:placed'); // promise form
for await (const [msg] of on(bus, 'message', { signal })) {
handle(msg); // async iterator form
}Setting captureRejections: true on an emitter routes rejected promises from async listeners to the error event instead of leaving them unhandled.
Note: If an interviewer asks whether emit is asynchronous, the answer is no. That surprises many candidates and explains several ordering bugs.
29. How would you design a centralised error handler for an Express API so that every error returns a consistent response?
The goal is one place that turns any thrown error into a predictable HTTP response and a useful log line, so route handlers only throw and never format errors themselves.
1. Define typed application errors that carry a status and a machine-readable code.
class AppError extends Error {
constructor(status, code, message, details) {
super(message);
this.status = status; this.code = code; this.details = details;
}
}
const notFound = (what) => new AppError(404, 'NOT_FOUND', what + ' not found');2. Make async errors reach the handler. Express 5 forwards rejected promises automatically. In Express 4 wrap handlers, for example const wrap = (fn) => (req, res, next) => fn(req, res, next).catch(next);, or use express-async-errors.
3. Register a 404 handler, then the error handler last, with the four-argument signature.
app.use((req, res, next) => next(notFound('Route')));
app.use((err, req, res, next) => {
const status = err.status || 500;
if (status >= 500) req.log.error({ err }, 'unhandled error');
if (res.headersSent) return next(err);
res.status(status).json({
error: {
code: err.code || 'INTERNAL_ERROR',
message: status >= 500 ? 'Something went wrong' : err.message,
details: err.details,
requestId: req.id,
},
});
});- Never leak internals: no stack traces or SQL messages for 5xx errors in production.
- Map known library errors: validation failures to 400, unique-constraint violations to 409, JWT errors to 401.
- Include a request id so support can match a user's report with the log entry.
- Consider the standard
application/problem+jsonformat (RFC 9457) for the body.
Note: The headersSent check matters: if streaming has already started, you cannot send a JSON error, so delegate to Express's default handler, which closes the connection.
30. Compare JWT-based authentication with server-side sessions. How do you handle revocation, refresh tokens and token storage?
Server-side sessions store session data on the server (usually in Redis) and give the client only an opaque random id in a cookie. JWTs are signed tokens that carry claims themselves, so any service with the key can verify them without a lookup.
| Sessions | JWT | |
|---|---|---|
| Verification | Store lookup per request | Signature check, no lookup |
| Revocation | Delete the session, instant | Hard; valid until expiry |
| Size | Tiny cookie | Larger, sent on every request |
| Best fit | A single web app | Many services, mobile clients, third-party APIs |
Revocation with JWTs. Keep access tokens short-lived (5 to 15 minutes) and pair them with a refresh token that is long-lived, stored server-side, and revocable. For urgent cases, keep a small denylist of token ids (jti) in Redis, or a per-user token version that is checked on sensitive actions.
Refresh token rotation. Issue a new refresh token each time one is used and invalidate the old one. If an old token is presented again, assume theft and revoke the whole family.
Storage in the browser. Prefer an HttpOnly, Secure, SameSite cookie, which JavaScript cannot read, so XSS cannot steal it. localStorage is readable by any injected script. Cookies bring CSRF considerations, which SameSite and CSRF tokens address.
JWT hygiene: pin the algorithm when verifying (never accept none), validate exp, iss and aud, keep payloads free of secrets because they are only encoded, not encrypted, and rotate signing keys.
Note: Many teams reach for JWTs by default. For a single server-rendered or same-site app, sessions are simpler and safer; saying so shows judgement.
31. How should passwords be stored in a Node.js application? Compare bcrypt, scrypt and Argon2.
Passwords must be stored with a slow, salted, one-way password hashing function, never encrypted and never hashed with a fast general-purpose hash such as MD5 or SHA-256. Fast hashes let an attacker with a leaked database try billions of guesses per second on a GPU.
- bcrypt: long-established, with a tunable cost factor (use 12 or so and raise it over time). Limitations: it only uses the first 72 bytes of input and is not memory-hard. Use the
bcryptorbcryptjspackage. - scrypt: memory-hard, which makes GPU and ASIC attacks expensive. Built into Node as
crypto.scrypt, so no dependency is needed. - Argon2id: winner of the Password Hashing Competition and the current OWASP first recommendation. Tunable for memory, time and parallelism. Use the
argon2package.
const argon2 = require('argon2');
const hash = await argon2.hash(password, { type: argon2.argon2id });
// stored string already contains algorithm, parameters and salt
const ok = await argon2.verify(hash, attempt);
if (ok && argon2.needsRehash(hash, currentOptions)) {
await saveNewHash(userId, await argon2.hash(attempt));
}Practical points:
- Always use the async API. These functions are deliberately slow and would block the event loop; the async versions run on the libuv thread pool.
- The library generates a unique random salt per password. A pepper (a secret stored outside the database) is an optional extra layer.
- Rate-limit login attempts, because slow hashing also makes login an easy denial-of-service target.
- Rehash on login when you upgrade parameters, as shown above.
Note: Comparing hashes yourself with an equality check is a mistake. Use the library's verify function, which also compares in constant time.
32. How do you validate input at the boundaries of a Node.js API, and what is a mass assignment vulnerability?
Every piece of data from outside the process, including the body, query string, route params, headers, webhooks and even messages from other internal services, is untrusted until it has been checked against an explicit schema. Validation belongs at the edge, before the data reaches business logic or the database.
Use a schema library such as zod, joi, yup or ajv (JSON Schema). A schema both validates and documents the shape, and with zod gives you a TypeScript type for free.
const { z } = require('zod');
const CreateUser = z.object({
email: z.string().email().max(254),
name: z.string().trim().min(1).max(100),
age: z.coerce.number().int().min(18).optional(),
}).strict(); // reject unknown keys
app.post('/users', (req, res, next) => {
const parsed = CreateUser.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ errors: parsed.error.issues });
createUser(parsed.data).then((u) => res.status(201).json(u), next);
});Mass assignment happens when you pass the whole request body straight to the model, for example User.create(req.body) or Object.assign(user, req.body). An attacker adds a field such as role: 'admin' or isVerified: true and the ORM happily saves it. The fix is an allowlist: only copy the fields your schema declares, which the strict schema above enforces.
Other boundary checks:
- Limit body size, for example
express.json({ limit: '100kb' }). - Reject objects where strings are expected, which blocks NoSQL operator injection such as
{ "$gt": "" }. - Use parameterised queries even after validation.
- Guard against prototype pollution by rejecting
__proto__andconstructorkeys.
Note: Validation is not the same as output encoding. Validating input does not remove the need to escape data when it is rendered as HTML.
33. How do you protect a Node.js project against npm supply chain attacks such as typosquatting and malicious install scripts?
A typical Node project pulls in hundreds of transitive packages, and any one of them runs with full access to your machine, your CI secrets and your production environment. Supply chain defence is about reducing, pinning and verifying that code.
- Commit the lockfile and install with
npm ciin CI and Docker builds. It installs exactly what the lockfile records, including integrity hashes, and fails ifpackage.jsonand the lockfile disagree. - Beware install scripts.
preinstallandpostinstallscripts run arbitrary code the moment you install. Many real attacks, such as hijacked popular packages, used them to steal tokens. Considernpm ci --ignore-scriptsin CI, allowlisting only packages that genuinely need a build step. pnpm blocks dependency scripts by default in recent versions. - Typosquatting. Check names carefully (
crossenvversuscross-env), look at download counts, repository links and maintainers before adding anything, and prefer scoped packages from known publishers. - Provenance.
npm audit signaturesverifies registry signatures and provenance attestations that link a package to the source repository and CI build that produced it. Publish your own packages with--provenance. - Automated scanning.
npm audit, Dependabot, Snyk or Socket in CI, with a triage process so alerts are not ignored. - Least privilege. Use short-lived, scoped npm tokens with 2FA, keep secrets out of build environments that run installs, and consider a private registry proxy.
- Fewer dependencies. Native
fetch,node:test,crypto.randomUUID()andutil.parseArgsreplace many small packages.
Note: Pinning exact versions in package.json alone is not enough, because transitive dependencies still float. The lockfile plus npm ci is what makes installs reproducible.
34. Explain semantic versioning ranges in package.json, what peerDependencies are, and how npm, pnpm and Yarn differ.
Semantic versioning is MAJOR.MINOR.PATCH: breaking changes bump major, new backward-compatible features bump minor, and fixes bump patch.
^1.4.2(the npm default) allows any1.x.xat or above 1.4.2, but not 2.0.0. For versions below 1, the caret is stricter:^0.4.2only allows 0.4.x.~1.4.2allows only patch updates, so 1.4.x.1.4.2pins an exact version.
Ranges describe what is acceptable; the lockfile records what was actually installed, which is why both matter.
Dependency types
dependencies: needed at runtime.devDependencies: build and test tools only.peerDependencies: “I work with your copy of this package, I do not bring my own.” Plugins use it, for example an Express middleware or a React component library, so the app ends up with one shared instance rather than two conflicting copies. npm 7 and later install peers automatically and fail on incompatible ranges.optionalDependencies: install failures are tolerated.
Package managers
- npm: bundled with Node, flat
node_modules, which allows code to import packages it never declared (phantom dependencies). - pnpm: a content-addressed global store with hard links, much faster and far lighter on disk, and a strict layout that blocks phantom dependencies.
- Yarn: Classic behaves much like npm; Berry adds Plug'n'Play with no node_modules at all.
All three support workspaces for monorepos, linking local packages together. Corepack, via the packageManager field, pins which manager and version a project uses.
Note: Mixing package managers in one repository produces two lockfiles that drift apart. Pick one and enforce it in CI.
35. What caching strategies would you use in a Node.js service? Compare in-memory caches with Redis and explain invalidation and cache stampedes.
Caching trades freshness for speed, so the first questions are always: how stale can this data be, and what happens when the cache is wrong?
Where to cache
- In-process memory (an LRU such as
lru-cache): nanosecond reads and no network hop, but each instance has its own copy, the cache is lost on restart, and it eats heap. Always bound it by size, never an unbounded plain object. - Redis or Memcached: shared across all instances, survives deploys, supports TTLs and atomic operations. Costs a network round trip of around a millisecond.
- HTTP and CDN caching with
Cache-ControlandETagfor public responses, which removes the request entirely.
Cache-aside is the most common pattern:
async function getProduct(id) {
const key = 'product:' + id;
const hit = await redis.get(key);
if (hit) return JSON.parse(hit);
const product = await db.products.findById(id);
await redis.set(key, JSON.stringify(product), 'EX', 300);
return product;
}Invalidation. Combine a TTL as a safety net with explicit deletion when data changes (delete the key after the database write commits). Versioned keys such as product:42:v7 avoid race conditions.
Cache stampede. When a hot key expires, hundreds of requests miss at once and all hit the database. Defences:
- Request coalescing: keep a map of in-flight promises so concurrent misses share one query.
- A short lock in Redis (
SET key NX PX) so only one instance recomputes. - Jittered TTLs so keys do not all expire together, and stale-while-revalidate refreshing in the background.
Note: Never cache per-user or authenticated responses under a shared key. Include the user or tenant in the key, or do not cache it at all.
36. How do you implement structured logging in a Node.js service, and what makes logs useful in production?
Production logs are read by machines first and people second, so they should be structured JSON, one event per line, rather than free-form strings built with console.log.
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
redact: ['req.headers.authorization', 'password', '*.cardNumber'],
});
logger.info({ orderId, userId, durationMs: 42 }, 'order placed');
// {"level":30,"time":...,"orderId":"o_1","userId":"u_9","durationMs":42,"msg":"order placed"}What makes logs useful:
- A fast, asynchronous logger. pino is the usual choice because it is designed to add minimal overhead; winston is more configurable but slower. Pretty-printing belongs in development only.
- Levels used consistently:
errorfor things someone must act on,warnfor degraded but handled,infofor business events,debugoff in production unless switched on temporarily. - Correlation ids. Generate or accept an
x-request-idat the edge, attach it to every log line for that request (pino-http does this with child loggers, and AsyncLocalStorage can carry it through deep calls), and forward it to downstream services. This is what lets you follow one request across the system. - Context as fields, not interpolated strings, so you can filter by
userIdororderIdin your log platform. - Log errors with the error object so the stack and cause are captured.
- Redaction. Never log passwords, tokens, full card numbers or unnecessary personal data; this is also a data protection requirement under laws such as India's DPDP Act.
- Write to stdout and let the platform ship logs, rather than managing log files in the app.
Note: Logs, metrics and traces are complementary. Use metrics for alerting, traces for latency breakdowns and logs for the detail. OpenTelemetry can tie all three together with the same trace id.
37. What is AsyncLocalStorage in Node.js, and how would you use it to carry request context across asynchronous calls?
AsyncLocalStorage, from node:async_hooks, gives you a store that follows the logical flow of an asynchronous operation. Anything started inside als.run(store, fn), including awaited promises, timers and callbacks, can read that same store later, without passing it through every function argument. It is Node's equivalent of thread-local storage.
The classic use is request context: a request id, the authenticated user, the tenant, or a transaction handle.
const { AsyncLocalStorage } = require('node:async_hooks');
const { randomUUID } = require('node:crypto');
const als = new AsyncLocalStorage();
app.use((req, res, next) => {
const ctx = { requestId: req.get('x-request-id') || randomUUID(), userId: null };
als.run(ctx, next);
});
// deep inside a repository, no req passed in
function log(msg, extra) {
const ctx = als.getStore();
logger.info({ ...extra, requestId: ctx?.requestId }, msg);
}Where it is used in practice:
- Adding correlation ids to every log line automatically.
- Multi-tenant apps that pick a database schema based on the current tenant.
- APM and OpenTelemetry tracing, which rely on the same mechanism to link spans.
- Frameworks such as Next.js use it internally for request-scoped APIs.
Things to watch:
- Context can be lost with libraries that queue callbacks in custom ways (some older connection pools);
AsyncResource.bindfixes those cases. - Keep the store small and request-scoped; it is not a place for global caches.
- It is explicit about where context comes from only if the team knows it exists, so document it.
Note: AsyncLocalStorage is stable and is the supported way to do this. Avoid building on the lower-level async_hooks createHook API, which is experimental and has a real performance cost.
38. How do you test a Node.js API? Explain where unit tests, integration tests with supertest, test databases and mocking each fit.
A good Node.js test suite is weighted towards fast integration tests at the HTTP boundary, with unit tests for pure logic and a small number of end-to-end checks. Mocks are used at the edges of your system, not everywhere.
- Unit tests cover pure functions and business rules: pricing, validation schemas, date calculations. They run in milliseconds and need no I/O.
- Integration tests exercise real routes, middleware, validation and database queries together.
supertestsends requests to your Express app without opening a real port, so export the app separately from the code that callslisten(). - End-to-end tests hit a deployed environment for a handful of critical journeys.
// users.test.js, using the built-in runner (node --test)
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const request = require('supertest');
const app = require('../src/app');
before(async () => { await db.migrate.latest(); });
after(async () => { await db.destroy(); });
test('POST /users rejects an invalid email', async () => {
const res = await request(app).post('/users').send({ email: 'nope' });
assert.equal(res.status, 400);
});Test databases. Use the same engine as production, not SQLite standing in for PostgreSQL. Testcontainers or a Docker Compose service gives a real, disposable instance. Isolate tests by wrapping each in a transaction that is rolled back, or by truncating tables between tests.
Mocking. Mock things you do not own and cannot run locally: payment gateways, SMS and email providers, third-party APIs. Tools include nock or undici's MockAgent for HTTP, mock.fn() in node:test, and fake timers for time-based logic. Mocking your own database layer everywhere produces tests that pass while production breaks.
Runners: node:test is built in and fast, Jest is popular with a rich ecosystem, and Vitest suits ESM and TypeScript projects well.
Note: Test the failure paths too: timeouts from downstream services, validation errors, duplicate keys and unauthorised access. That is where most production bugs live.
39. How would you implement rate limiting for a Node.js API that runs on several instances? Which algorithms would you consider?
Rate limiting protects the API from abuse, brute-force login attempts and noisy clients, and keeps capacity fair between users. The two design questions are which algorithm and where the counters live.
Algorithms
- Fixed window: count requests per key per minute. Simple, but allows a burst of double the limit across a window boundary.
- Sliding window (log or counter approximation): smooths the boundary problem at a little more cost.
- Token bucket: tokens refill at a steady rate up to a maximum; each request spends one. Allows controlled bursts and is what most API gateways use.
- Leaky bucket: processes requests at a constant rate, queueing or dropping the excess.
Shared state. An in-memory counter only works on a single process. With several instances behind a load balancer each would allow the full limit, so store counters in Redis, using atomic INCR with an expiry or a Lua script so check-and-increment happens in one step.
const rateLimit = require('express-rate-limit');
const { RedisStore } = require('rate-limit-redis');
app.use('/api/', rateLimit({
windowMs: 60 * 1000,
limit: 100,
standardHeaders: 'draft-7', // RateLimit headers for clients
store: new RedisStore({ sendCommand: (...args) => redis.sendCommand(args) }),
}));Design details:
- Choose the key carefully: API key or user id for authenticated traffic, IP for anonymous traffic. Behind a proxy, configure
app.set('trust proxy', 1)or every request appears to come from the load balancer. - Different limits per route: stricter on login, OTP and password reset endpoints.
- Respond with 429 and a
Retry-Afterheader. - Layer it. A CDN, WAF or API gateway can absorb volumetric attacks before they reach Node at all.
Note: Decide what happens if Redis is down. Failing open keeps the API available, failing closed protects it; for login endpoints many teams choose to fail closed.
40. What does it take to scale a Node.js application horizontally across many servers or containers?
Horizontal scaling means running many identical instances behind a load balancer. Node makes that easy only if the application is stateless: any instance must be able to serve any request.
Remove local state
- Sessions go in Redis or a database, or use signed tokens, instead of an in-memory session store.
- Uploaded files go to object storage such as S3, not the local disk.
- Caches that must be consistent move to Redis; small per-instance LRU caches are fine for data that tolerates staleness.
- Scheduled jobs must not run on every instance. Use a job queue or a distributed lock so a cron task runs once.
- WebSocket fan-out needs a shared pub/sub layer such as a Redis adapter.
Load balancing
- A load balancer (Nginx, an AWS ALB, a Kubernetes Service) distributes requests and uses health checks to drop unhealthy instances.
- Sticky sessions pin a client to one instance. They are a workaround for local state, needed for Socket.IO's HTTP long-polling fallback, but they unbalance load and should not be relied on for correctness.
Process model. On a VM, PM2 in cluster mode or the cluster module uses all cores. In containers the usual approach is one Node process per container with CPU limits, and the orchestrator adds replicas, with autoscaling on CPU, request rate or queue depth.
The bottleneck moves. More app instances mean more database connections: size pools per instance, add a connection pooler such as PgBouncer, add read replicas, and cache. Make startup fast and shutdown graceful so scaling events and deploys do not drop requests.
Note: Twelve-factor principles summarise most of this: config from the environment, stateless processes, logs to stdout and disposable instances.
41. How do you profile CPU usage in a Node.js application and find the code that is making it slow?
When a Node service is slow and CPU is high, the question is which JavaScript is keeping the single main thread busy. Profiling answers that with evidence rather than guesses.
1. Confirm it is CPU-bound. Check event loop delay with perf_hooks.monitorEventLoopDelay() and process CPU. High CPU with high loop delay points to synchronous JavaScript; low CPU with slow responses points to waiting on I/O, where tracing is the better tool.
2. Capture a profile
node --cpu-prof app.jswrites a.cpuprofilefile on exit, which opens in Chrome DevTools.node --inspect app.js, then openchrome://inspectand record a profile while you replay load. On a remote server, sendSIGUSR1to enable the inspector and tunnel the port over SSH rather than exposing it.- Clinic.js (
clinic doctor,clinic flame) combines load generation and diagnosis into one report. node --profwith--prof-processgives the low-level V8 tick profile.
3. Read the flame graph. The x-axis is share of samples, not time order. Look for wide plateaus: functions that were on the CPU for a large share of samples. Common culprits: JSON.parse or JSON.stringify on large payloads, synchronous crypto or compression, catastrophic regular expression backtracking, deep cloning, heavy logging with pretty-printing, and template rendering.
4. Fix and re-measure under the same load, generated with autocannon or k6.
npx autocannon -c 50 -d 30 http://localhost:3000/report &
node --cpu-prof --cpu-prof-dir=./profiles app.jsTypical fixes are caching results, streaming instead of building large strings, moving heavy work to a worker thread, or replacing a pathological regex.
Note: Profile with production-like data. A function that is harmless with 10 rows can dominate a flame graph with 100,000.
42. Why do Node.js applications use database connection pooling, and how do you size and manage a pool correctly?
Opening a database connection is expensive: a TCP handshake, often TLS, authentication, and server-side memory for the session. Doing that per request adds latency and can exhaust the database's connection limit under load. A pool keeps a set of open connections and lends them out.
const { Pool } = require('pg');
const pool = new Pool({
max: 10, // per process
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000, // fail fast when the pool is exhausted
});
const { rows } = await pool.query('SELECT * FROM orders WHERE id = $1', [id]);Sizing. Bigger is not better. Databases slow down with too many concurrent queries because of context switching and lock contention. Start small (around 10 per process) and remember the multiplication: 20 containers with a pool of 10 each is 200 connections, which may exceed the database's max_connections. Use a server-side pooler such as PgBouncer or RDS Proxy when instances or serverless functions multiply.
Managing it correctly:
- Always release connections. When you check one out for a transaction, release it in a
finallyblock. A leaked client eventually leaves every request waiting for a free connection. - Use one pool per process, created at startup, not one per request or per module.
- Keep transactions short, and never hold a connection while awaiting an external HTTP call.
- Set timeouts: acquisition timeouts, statement timeouts, and idle timeouts so connections closed by firewalls are replaced.
- Monitor total, idle and waiting counts, and alert when requests queue for connections.
- Close the pool on shutdown as part of graceful termination.
Note: In serverless environments such as AWS Lambda, create the pool outside the handler so warm invocations reuse it, and keep max very small.
43. How do you make outbound HTTP calls from Node.js efficient and safe, including keep-alive agents and timeouts?
Most Node services spend their time calling other services, so outbound HTTP hygiene has a large effect on latency and on resilience.
Reuse connections. Without keep-alive, every call pays for a new TCP and TLS handshake, which can cost more than the request itself, and it churns sockets into TIME_WAIT. Since Node 19 the default global agent uses keep-alive; the built-in fetch is based on undici, which pools connections per origin. Configure it explicitly when you need control over pool size.
const { Agent, setGlobalDispatcher } = require('undici');
setGlobalDispatcher(new Agent({ connections: 50, keepAliveTimeout: 10000 }));
const res = await fetch('https://api.partner.com/rates', {
signal: AbortSignal.timeout(3000), // total deadline for the call
});
if (!res.ok) throw new Error('partner responded ' + res.status);Always set timeouts. The default is effectively to wait a very long time. A dependency that hangs will hold your sockets and memory until your own service falls over. Use AbortSignal.timeout(), or AbortSignal.any() to combine a deadline with cancellation when the incoming request is aborted.
Other good practice:
- Consume or cancel every response body, otherwise the connection is not returned to the pool.
- Retry only idempotent requests, with backoff and jitter, and add a circuit breaker for dependencies that fail often.
- Limit concurrency to any one dependency so a slow partner cannot use every socket.
- Propagate context: forward request ids and trace headers.
- Guard against SSRF when the URL is influenced by user input: allowlist hosts and block internal address ranges.
On the server side, tune server.keepAliveTimeout to be longer than the load balancer's idle timeout, and set headersTimeout and requestTimeout to limit slow clients.
Note: A mismatched keepAliveTimeout behind an AWS ALB is a well-known cause of intermittent 502 errors: Node closes an idle socket just as the load balancer reuses it.
44. How would you handle large file uploads in a Node.js API without loading the whole file into memory?
Buffering an upload in memory means a few concurrent 500 MB uploads can crash the process. The answer is to stream the multipart body straight to its destination, enforce limits as bytes arrive, and ideally keep large files off your servers entirely.
Streaming the multipart body. Parsers such as busboy (which multer uses underneath) emit each file as a readable stream. Pipe it to object storage, so memory stays flat regardless of file size.
const busboy = require('busboy');
const { Upload } = require('@aws-sdk/lib-storage');
app.post('/upload', (req, res, next) => {
const bb = busboy({ headers: req.headers, limits: { fileSize: 50 * 1024 * 1024, files: 1 } });
bb.on('file', (name, file, info) => {
const upload = new Upload({ client: s3, params: {
Bucket: 'uploads', Key: crypto.randomUUID(), Body: file, ContentType: info.mimeType } });
let tooBig = false;
file.on('limit', () => { tooBig = true; upload.abort(); });
upload.done().then(
() => res.status(201).end(),
(err) => (tooBig ? res.status(413).end() : next(err)));
});
req.pipe(bb);
});Limits and validation:
- Enforce size and file count limits in the parser, and at the reverse proxy (
client_max_body_sizein Nginx). - Do not trust the client's filename or MIME type. Generate your own storage key, and check the real type from the file's magic bytes if it matters.
- Scan for malware where users share files, and never serve uploads from the same origin as your app without the right
Content-TypeandContent-Dispositionheaders.
Better still, bypass the server. Issue a pre-signed URL so the browser uploads directly to S3, then receive a notification or a confirmation call. Your Node process never touches the bytes, and multipart uploads in S3 handle resumable, very large files.
Note: Remember to clean up on failure: abort the storage upload if the client disconnects midway, so you are not left with partial objects.
45. How do WebSockets work with Node.js, and how do you scale a Socket.IO application across multiple server instances?
A WebSocket starts as an HTTP request with an Upgrade header; once the server agrees, the same TCP connection becomes a persistent, full-duplex channel. That suits chat, live dashboards, notifications and collaborative editing. Node handles many idle connections cheaply because each one is just a socket watched by the event loop.
Libraries. ws is a fast, minimal WebSocket implementation. Socket.IO adds rooms, acknowledgements, automatic reconnection and a long-polling fallback, at the cost of its own protocol, so clients must also use Socket.IO.
The scaling problem. With several instances, a user connected to server A will not receive an event emitted on server B. Two things fix that:
- An adapter for cross-instance broadcast. The Redis adapter publishes every broadcast over Redis pub/sub so each instance delivers it to its own local sockets.
- Sticky sessions at the load balancer if long-polling is enabled, because the handshake and subsequent polling requests must reach the same instance. If you allow only the WebSocket transport, stickiness is not needed.
const { Server } = require('socket.io');
const { createAdapter } = require('@socket.io/redis-adapter');
const pub = createClient({ url: process.env.REDIS_URL });
const sub = pub.duplicate();
await Promise.all([pub.connect(), sub.connect()]);
const io = new Server(httpServer, { adapter: createAdapter(pub, sub) });
io.to('order:42').emit('status', { state: 'shipped' }); // reaches every instanceOperational concerns:
- Authenticate during the handshake, not after, and re-check authorisation when joining rooms.
- Use heartbeats to detect dead connections, and raise proxy idle timeouts.
- Handle backpressure for slow clients so buffered messages do not grow without limit.
- Plan for reconnect storms after a deploy: roll instances gradually and add jitter to client reconnects.
Note: For one-way server-to-client updates, Server-Sent Events over plain HTTP are simpler to scale and often enough.
46. How do you manage configuration and secrets in a Node.js application, and why validate environment variables at startup?
The twelve-factor approach is to keep configuration in the environment, separate from code, so the same build artefact runs in development, staging and production with different settings.
Loading config
- In production, environment variables come from the platform: Kubernetes ConfigMaps and Secrets, ECS task definitions, or a secrets manager such as AWS Secrets Manager or HashiCorp Vault.
- Locally, a
.envfile is convenient. Node 20.6 and later can load it without a dependency usingnode --env-file=.env app.js; older projects usedotenv. The.envfile is git-ignored, with a committed.env.exampledocumenting the keys.
Validate once, at startup. Reading process.env.X ad hoc throughout the code means a missing or misspelt variable fails at 3 a.m. on the first request that needs it. Instead, parse everything into one typed, frozen config object and crash immediately if anything is wrong, so a bad deploy fails its health check and never receives traffic.
// config.js
const { z } = require('zod');
const schema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid configuration', parsed.error.flatten().fieldErrors);
process.exit(1);
}
module.exports = Object.freeze(parsed.data);Remember that every environment variable is a string: 'false' is truthy, which is why coercion belongs in the schema.
Secrets hygiene: never commit secrets or bake them into Docker images, never log the config object, rotate credentials, give each service only the secrets it needs, and scan repositories for leaked keys.
Note: Avoid branching on NODE_ENV for behaviour. Use explicit feature settings instead, so production and staging behave the same apart from their configuration.
47. How do you convert callback-based Node.js APIs to promises using util.promisify, fs/promises and events.once?
Older Node APIs use the error-first callback style. Mixing callbacks with async/await leads to lost errors and nested code, so modern code converts them to promises at the boundary.
1. Use the promise versions that already exist. Most core modules now ship them:
const fs = require('node:fs/promises');
const { setTimeout: sleep } = require('node:timers/promises');
const { pipeline } = require('node:stream/promises');
const dns = require('node:dns/promises');
const text = await fs.readFile('config.json', 'utf8');
await sleep(500);2. Wrap anything else with util.promisify. It works with any function whose last argument is an error-first callback that is called once.
const { promisify } = require('node:util');
const { execFile } = require('node:child_process');
const execFileP = promisify(execFile);
const { stdout } = await execFileP('git', ['rev-parse', 'HEAD']);Watch the edge cases: methods that rely on this must be bound first (promisify(client.get).bind(client) or promisify(client.get.bind(client))), and callbacks with several result values need a custom implementation, which a library can expose through util.promisify.custom.
3. Turn events into promises with events.once. It resolves with the event's arguments as an array and rejects if error is emitted first.
const { once } = require('node:events');
server.listen(3000);
await once(server, 'listening');For repeated events, events.on(emitter, name) returns an async iterator you can consume with for await. Going the other way, util.callbackify wraps an async function for code that still expects a callback.
Note: Do not wrap functions that call their callback more than once, such as a progress callback. A promise can settle only once, so later calls are silently ignored.
48. How would you run background jobs in a Node.js system using a queue such as BullMQ, and how do you handle retries and idempotency?
Anything slow or failure-prone that the user does not need to wait for, such as sending email or SMS, generating PDFs, resizing images or calling a flaky partner API, belongs in a background job. The API enqueues and returns quickly; separate worker processes do the work.
BullMQ is the common Redis-backed choice in Node. Alternatives include RabbitMQ, SQS and Kafka for larger systems.
const { Queue, Worker } = require('bullmq');
const connection = { host: 'redis', port: 6379 };
const emails = new Queue('emails', { connection });
// producer (inside the API)
await emails.add('welcome', { userId }, {
jobId: 'welcome:' + userId, // de-duplicates
attempts: 5,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: 1000,
});
// consumer (separate worker process)
new Worker('emails', async (job) => {
await sendWelcomeEmail(job.data.userId);
}, { connection, concurrency: 10 });Retries. Use a bounded number of attempts with exponential backoff, and distinguish permanent failures (invalid address: do not retry, BullMQ offers UnrecoverableError) from transient ones (timeout: retry). Jobs that exhaust their attempts should land somewhere visible, a failed set or a dead letter queue, with alerting.
Idempotency. Queues give at-least-once delivery: a worker can crash after doing the work but before acknowledging it, so the job runs again. Design handlers so a repeat is harmless:
- Use deterministic job ids to avoid enqueueing duplicates.
- Record completion in the database, for example a
welcome_sent_atcolumn, and check it first. - Pass idempotency keys to payment and messaging providers.
Reliable enqueueing. If the job must be created only when a database transaction commits, use the outbox pattern: write the job into an outbox table in the same transaction, then publish it from there.
Note: Run workers as a separate deployment from the API, so a burst of heavy jobs cannot slow down user-facing requests, and scale them on queue depth.
49. How do you configure CORS correctly for a Node.js API that uses cookie-based authentication, and how does that relate to CSRF?
CORS is a browser mechanism that decides whether JavaScript on one origin may read responses from another. It relaxes the same-origin policy; it is not a server-side access control, and non-browser clients ignore it completely.
With cookies, the configuration must be exact:
- The client must send
credentials: 'include'with fetch (orwithCredentialswith axios). - The server must respond with
Access-Control-Allow-Credentials: true. Access-Control-Allow-Originmust name the specific origin. The wildcard*is not allowed with credentials.- Never reflect whatever
Originthe request sends; check it against an allowlist, and sendVary: Originso caches do not mix responses.
const cors = require('cors');
const allowed = new Set(['https://app.example.in', 'https://admin.example.in']);
app.use(cors({
origin: (origin, cb) => cb(null, !origin || allowed.has(origin)),
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
maxAge: 600, // cache preflight results
}));Where CSRF comes in. CORS stops a malicious site from reading your responses, but a browser still sends cookies with some cross-site requests, such as a form POST, which is exactly what CSRF exploits. Defences:
- SameSite cookies:
Lax(the modern browser default) blocks cookies on most cross-site POSTs;Strictblocks more. If front end and API are on different sites and you needSameSite=None, you must add other protection. - CSRF tokens (synchroniser or double-submit pattern) for state-changing requests.
- Require a JSON content type or a custom header, which forces a preflight that a hostile origin will fail.
- Check the
Originheader on state-changing requests.
Note: Hosting the front end and API on subdomains of the same registrable domain, such as app.example.in and api.example.in, makes them same-site, which keeps SameSite=Lax cookies working and simplifies everything.
50. Which modern Node.js built-in features let you drop third-party dependencies, such as fetch, the test runner, watch mode and the permission model?
Recent Node releases have absorbed many jobs that used to need packages. Using built-ins means fewer supply chain risks, smaller installs and less upgrade churn.
| Built-in | Replaces | Notes |
|---|---|---|
Global fetch, AbortSignal.timeout | axios, node-fetch, request | Stable since Node 21, based on undici |
node --test, node:test, node:assert | Mocha, parts of Jest | Mocks, fake timers, coverage, snapshots |
node --watch | nodemon | Restarts on file changes |
--env-file=.env, process.loadEnvFile() | dotenv | Node 20.6 and later |
util.parseArgs | yargs, minimist | For simple CLIs |
util.styleText | chalk | Terminal colours |
crypto.randomUUID() | uuid | Version 4 UUIDs |
structuredClone | lodash cloneDeep | Deep copies |
WebSocket client | ws (client side) | Global in Node 22 |
node:sqlite | better-sqlite3 | Newer, check stability for your version |
The permission model. Running with --permission (stable from Node 22.13 and 23.5; earlier versions used --experimental-permission) denies file system, child process, worker and native addon access by default, and you grant only what is needed:
node --permission --allow-fs-read=/app --allow-fs-write=/app/tmp server.jsIt limits the damage a compromised dependency can do, although it is not a full sandbox against malicious code and does not yet restrict network access.
Other useful additions: native TypeScript type stripping for running .ts files directly in recent versions, require() of synchronous ES modules, and single executable applications.
Note: Check the Node version you deploy on before relying on a feature. Many of these moved from experimental to stable across the 20, 22 and 24 LTS lines, and the flags changed on the way.