Node.js Interview Questions and Answers: 60 Questions from Easy to Hard
This guide is for Indian job seekers preparing for Node.js backend roles, from freshers facing their first technical round to experienced developers aiming for senior positions at product companies and service firms. It covers what interviewers ask most in 2025 and 2026 on Node 20 and 22: the event loop and libuv, modules, async patterns, streams and buffers, Express middleware, error handling, clustering and worker threads, security, performance, REST API design and testing. Questions are grouped as easy, medium and hard. Read each answer, run the code yourself, and then explain it aloud in your own words.
What Node.js interviews cover
- A first round on JavaScript and Node basics: the event loop, callbacks versus promises, async/await, modules, and how npm manages dependencies.
- Hands-on Express questions on routing, middleware order, request validation, error-handling middleware and how you would structure a growing codebase.
- Output-prediction puzzles mixing setTimeout, setImmediate, process.nextTick and promises, which test whether you really understand the event loop.
- For experienced roles, design discussions on scaling, worker threads, streams for large files, caching, rate limiting and securing a REST API.
- A live coding task such as building a small CRUD API, limiting concurrency of async calls, or finding why an endpoint is slow.
How freshers should prepare
- Get JavaScript fundamentals solid first: closures, this, promises and array methods. Most Node rounds start with plain JavaScript before any framework.
- Build one small REST API with Express and a database, deploy it on a free tier, and be ready to walk through every file.
- Practise explaining the event loop in two minutes with a simple diagram, because almost every interviewer asks about it.
- Write the same async code with callbacks, promises and async/await so you can convert between the three styles confidently.
- Learn basic Git, npm scripts and environment variables, since freshers are often asked how they run and configure a project.
How experienced candidates should prepare
- Prepare two or three production stories with numbers: latency you cut, a memory leak you fixed or traffic you scaled, and what you would change now.
- Revise event loop internals, the libuv thread pool, streams and backpressure, because senior rounds probe why something is slow, not just how to code it.
- Be ready to design an API end to end: authentication, pagination, idempotency, rate limiting, logging, error format and deployment.
- Know the trade-offs between cluster, worker threads, containers and job queues, and when each is the right choice for a workload.
- Read the Node 20 and 22 release notes so you can discuss the built-in test runner, fetch, the --env-file flag and require of ES modules.
Common mistakes to avoid
- Saying Node.js is completely single-threaded without mentioning the libuv thread pool and the operating system's asynchronous network I/O.
- Using async callbacks inside forEach and expecting the loop to wait, or forgetting to await a promise inside try/catch.
- Running CPU-heavy work such as huge JSON parsing, image resizing or high-cost password hashing on the main thread of a busy API.
- Swallowing errors in empty catch blocks, or keeping the process running after an uncaught exception instead of logging and restarting cleanly.
- Building SQL queries by string concatenation or trusting request input without validation, which interviewers treat as a red flag.
Node.js interview questions: Easy questions (freshers)
1. What is Node.js, and why is it a good fit for I/O-heavy applications?
Node.js is a JavaScript runtime built on Google's V8 engine and the libuv library, used to run JavaScript outside the browser. Its strength is non-blocking I/O: when your code reads a file, queries a database or calls an API, Node hands the work to the operating system or a thread pool and carries on serving other requests. When the result is ready, a callback is queued and runs on the main thread. This lets one process hold thousands of concurrent connections cheaply, which suits REST APIs, chat servers and streaming. It is a weaker fit for long CPU-bound work unless you move that work to worker threads.
2. If Node.js is single-threaded, how does it handle many requests at the same time?
Only your JavaScript runs on a single main thread; the waiting happens elsewhere. Network sockets are watched by the operating system's asynchronous mechanisms (epoll on Linux, kqueue on macOS, IOCP on Windows), while tasks such as most file system calls, dns.lookup and some crypto and zlib functions run on libuv's thread pool, which has four threads by default. The event loop picks up finished operations and runs their callbacks one at a time. So Node gets concurrency by never sitting idle while it waits for I/O, not by running your JavaScript in parallel. The catch is that any slow synchronous code blocks every request.
3. What is the difference between CommonJS and ES modules in Node.js?
CommonJS uses require() and module.exports, loads modules synchronously and was Node's original system. ES modules use import and export, are the JavaScript standard and support top-level await. Node treats a file as an ES module if it ends in .mjs or if the nearest package.json has "type": "module"; .cjs files are always CommonJS. ESM imports are resolved statically before any code runs, which enables tooling such as tree shaking. In ESM you must write the file extension in relative imports, and __dirname is not defined, so you use import.meta.dirname instead. New projects generally choose ESM.
// math.mjs
export const add = (a, b) => a + b;
// app.mjs
import { add } from './math.mjs';
console.log(add(2, 3)); // 5 4. What is the difference between dependencies and devDependencies in package.json?
dependencies are packages your application needs at runtime, such as express or a database driver. devDependencies are needed only while developing or building, such as test runners, linters, TypeScript and bundlers. You add them with npm install express and npm install --save-dev eslint. When you deploy with npm ci --omit=dev, dev packages are skipped, which keeps the production image smaller and reduces the attack surface. A common mistake is putting a runtime library under devDependencies: the app works on your laptop but crashes in production with "Cannot find module".
5. Why should package-lock.json be committed, and what does npm ci do?
package-lock.json records the exact version and integrity hash of every installed package, including nested dependencies. package.json usually holds ranges such as ^5.1.0, so without the lock file two developers or two builds could install different versions. Committing it makes installs reproducible. npm ci is the command for CI servers and deployments: it removes any existing node_modules, installs exactly what the lock file says, and fails if package.json and the lock file disagree. Unlike npm install, it never rewrites the lock file, so builds are faster and predictable.
6. What is an error-first callback, and what is callback hell?
An error-first callback is Node's original async convention: the last argument of a function is a callback whose first parameter is an error (null on success) and whose later parameters carry the result. You always check the error before using the data. Callback hell is what you get when several dependent async steps are nested inside each other: deeply indented code where error handling is repeated at every level and easy to miss. Today you avoid it with promises and async/await. Most core modules have promise versions, such as node:fs/promises, and util.promisify converts older callback APIs.
const fs = require('node:fs');
fs.readFile('config.json', 'utf8', (err, data) => {
if (err) {
console.error('Read failed:', err.message);
return;
}
console.log(JSON.parse(data));
}); 7. What is a Promise, and what states can it be in?
A Promise is an object that represents the eventual result of an async operation. It starts as pending and then settles exactly once, either fulfilled with a value or rejected with a reason; after that its state never changes. You react with .then() for success, .catch() for errors and .finally() for cleanup, and each of these returns a new promise, which is why calls can be chained. Promise callbacks always run asynchronously as microtasks, even if the promise is already settled. A rejected promise that nobody handles raises an unhandled rejection, which crashes the process by default in current Node versions.
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
wait(500)
.then(() => 'done')
.then((msg) => console.log(msg))
.catch((err) => console.error(err))
.finally(() => console.log('cleanup')); 8. How does async/await work, and how is it related to promises?
async/await is syntax on top of promises. An async function always returns a promise: a returned value becomes a fulfilled promise and a thrown error becomes a rejected one. Inside it, await pauses that function until the awaited promise settles, then resumes with its value or throws its rejection reason, so ordinary try/catch works. Only that function pauses; the event loop keeps running other work meanwhile. The code reads top to bottom like synchronous code, which makes error handling and debugging easier than long .then() chains. Remember that awaiting two independent calls one after another makes them run sequentially, not in parallel.
const { readFile } = require('node:fs/promises');
async function loadConfig(path) {
try {
const text = await readFile(path, 'utf8');
return JSON.parse(text);
} catch (err) {
throw new Error(`Cannot load ${path}: ${err.message}`);
}
}
loadConfig('config.json').then(console.log).catch(console.error); 9. What are __dirname and __filename, and how do you get them in an ES module?
In CommonJS, __filename is the absolute path of the current file and __dirname is the folder that contains it. They help you build paths that do not depend on where the process was started, because relative paths in fs calls resolve against process.cwd(), not against the file's location. ES modules do not define these variables. On Node 20.11 and later you can use import.meta.filename and import.meta.dirname instead. On older versions you derive them with fileURLToPath(import.meta.url) from node:url, followed by path.dirname().
// paths.mjs (Node 20.11+)
import path from 'node:path';
const templates = path.join(import.meta.dirname, 'templates');
console.log(import.meta.filename);
console.log(templates); 10. What happens when you call require() on the same module twice?
The module's code runs only once. The first require() resolves the path, reads the file, wraps it in a function, executes it and stores the result in require.cache, keyed by the resolved filename. Later calls return the same module.exports object without running the file again. This is why a module that creates a database pool can be required from many files and they all share one pool. It also means module-level state is shared, so mutating an exported object affects every file that imported it. ES modules are cached the same way, keyed by their resolved URL.
11. What is middleware in Express?
Middleware is a function with the signature (req, res, next) that runs during the request-response cycle. Each one can read or modify req and res, end the response, or call next() to pass control to the next function in the stack. Express runs middleware in the order it was registered, so order matters: body parsers and logging come before routes, and the error handler goes last. Typical uses are parsing JSON, authentication, logging, CORS and rate limiting. If a middleware neither sends a response nor calls next(), the request hangs until the client times out.
const express = require('express');
const app = express();
app.use(express.json());
app.use((req, res, next) => {
console.log(req.method, req.url);
next();
});
app.get('/health', (req, res) => res.json({ ok: true }));
app.listen(3000); 12. How do you read route parameters, query strings and the JSON body in Express?
Route parameters come from named segments in the path and are available on req.params, always as strings. Query string values are on req.query; they arrive as strings, or as arrays when a key repeats, so convert and validate them. The parsed request body is on req.body, but only after you register a body parser such as express.json(); without it req.body is undefined. Never trust any of these values directly: check types, lengths and ranges, for example with a schema validation library, before using them in a database query.
const express = require('express');
const app = express();
app.use(express.json());
// PUT /users/42?notify=true with body {"name":"Asha"}
app.put('/users/:id', (req, res) => {
const id = Number(req.params.id);
const notify = req.query.notify === 'true';
const { name } = req.body;
res.json({ id, notify, name });
});
app.listen(3000); 13. How do you handle errors in async/await code in Node.js?
Wrap awaited calls in try/catch, or attach .catch() to the promise that the async function returns. Handle an error at the level that can do something useful with it, such as retrying, returning a 4xx response or logging with context, and rethrow it otherwise. Never leave a promise floating without await or .catch(): since Node 15 an unhandled rejection crashes the process by default. When you wrap an error, pass the original as cause so the stack trace is not lost. Note the return await inside try below: without await, a rejection from res.json() would skip this catch block.
async function getUser(id) {
try {
const res = await fetch(`https://api.example.com/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
throw new Error(`getUser(${id}) failed`, { cause: err });
}
} 14. What is a Buffer in Node.js?
A Buffer is a fixed-length sequence of raw bytes, Node's way of handling binary data such as files, images, network packets and encrypted output. Buffer is a subclass of Uint8Array, and its memory lives outside the V8 heap. You create one with Buffer.from() for existing data or Buffer.alloc(size) for a zero-filled block; Buffer.allocUnsafe() is faster but may contain old memory, so you must overwrite all of it. Converting between strings and bytes always involves an encoding such as utf8, base64 or hex. Note that a string's length counts UTF-16 code units while a buffer's length counts bytes.
const text = 'नमस्ते';
const buf = Buffer.from(text, 'utf8');
console.log(text.length); // 6
console.log(buf.length); // 18
console.log(buf.toString('base64')); 15. What are streams in Node.js, and what are the four types?
A stream processes data piece by piece instead of loading all of it into memory. That lets you handle a 5 GB file or a large upload with small, constant memory, and start sending output before all input has arrived. The four types are Readable (a source, such as fs.createReadStream or an incoming HTTP request), Writable (a destination, such as a file write stream or the HTTP response), Duplex (readable and writable independently, such as a TCP socket) and Transform (a duplex that changes data as it passes through, such as zlib.createGzip). Streams are EventEmitters and emit events such as data, end, error and finish.
16. What is the EventEmitter class, and where does Node use it?
EventEmitter, from node:events, implements the publish-subscribe pattern: you register listeners with on() or once() and trigger them with emit(). When emit() is called, the listeners run synchronously in the order they were added. Much of Node's core is built on it, including streams, HTTP servers, sockets and process. One special rule: if an 'error' event is emitted and no listener is registered for it, Node throws the error, and if nothing catches it the process crashes. So always add an error listener to emitters you create or consume.
const { EventEmitter } = require('node:events');
const orders = new EventEmitter();
orders.on('placed', (order) => console.log('Send SMS for order', order.id));
orders.once('placed', () => console.log('First order today!'));
orders.on('error', (err) => console.error('Order error:', err.message));
orders.emit('placed', { id: 101 });
orders.emit('placed', { id: 102 }); 17. What is the difference between blocking and non-blocking code in Node.js?
Blocking code makes the main thread wait until an operation finishes, so nothing else can run in the meantime, including other users' requests. Non-blocking code starts the operation, returns immediately and handles the result later through a callback or promise. For example, fs.readFileSync stops the whole process while the disk is read, whereas readFile from node:fs/promises lets the event loop keep serving traffic. Synchronous APIs are fine during startup, such as reading config once, and in command-line scripts. Inside a request handler on a server, use the async versions and avoid long CPU loops.
18. Which HTTP methods and status codes would you use in a REST API?
Use GET to read, POST to create, PUT to replace a resource, PATCH to update part of it and DELETE to remove it. GET, PUT and DELETE should be idempotent: repeating the same request leaves the server in the same state. Common status codes are 200 OK, 201 Created for a new resource, 204 No Content, 400 Bad Request for malformed input, 401 Unauthorized when authentication is missing or invalid, 403 Forbidden when the user is known but not allowed, 404 Not Found, 409 Conflict for duplicates, 422 when your API uses it for failed validation, 429 Too Many Requests and 500 for unexpected server errors.
19. How do you manage configuration and secrets with environment variables in Node.js?
Read settings from process.env. Every value there is a string, so convert numbers and booleans yourself, and fail fast at startup if a required variable is missing. Keep secrets such as database passwords and API keys out of the code and out of Git. For local development you can keep them in a .env file that is listed in .gitignore; since Node 20.6 the built-in --env-file flag loads it without the dotenv package. In production, set variables through your hosting platform or a secrets manager.
// start with: node --env-file=.env server.js
const port = Number(process.env.PORT ?? 3000);
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
console.error('DATABASE_URL is not set');
process.exit(1);
}
console.log(`Starting on port ${port}`); 20. How do you write a basic unit test in Node.js without installing a framework?
Node 20 and later ship a stable built-in test runner, node:test, used together with node:assert. You group cases with describe and it (or test) and run them with node --test, which finds files such as *.test.js automatically. It supports async tests, hooks like beforeEach, mocking through the mock object and, with a flag, code coverage. Jest and Vitest are still common in existing projects, but knowing the built-in runner shows you are current and lets you test small packages with zero dependencies.
// sum.test.js run with: node --test
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const sum = (a, b) => a + b;
describe('sum', () => {
it('adds two numbers', () => {
assert.equal(sum(2, 3), 5);
});
}); Node.js interview questions: Medium questions (1-3 years)
21. Explain the phases of the Node.js event loop.
Each loop iteration goes through phases, each with its own queue. Timers runs expired setTimeout and setInterval callbacks. Pending callbacks runs some system-level callbacks deferred from the previous iteration, such as certain TCP errors. Idle and prepare are internal. Poll retrieves new I/O events and runs their callbacks, and blocks there waiting for I/O when nothing else is scheduled. Check runs setImmediate callbacks. Close callbacks handles events such as a socket's 'close'. After each individual callback, Node drains the microtask queues: first process.nextTick callbacks, then promise callbacks. The process exits when no timers, active handles or pending requests remain.
22. What does this code print, and why?
Run as a CommonJS script it prints sync, nextTick and promise, followed by timeout and immediate in an order that can change between runs. Synchronous code finishes first. Then Node drains the nextTick queue before the promise microtask queue. The setTimeout(0) versus setImmediate race depends on whether the 1 ms minimum timer delay has passed when the loop first reaches the timers phase, which depends on machine load. Inside an I/O callback, setImmediate always runs before a zero-delay timer because the check phase comes right after poll. In an ES module the order of the first two flips: promise prints before nextTick, because the module body itself runs as part of a promise job.
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
console.log('sync'); 23. What is the difference between Promise.all, Promise.allSettled, Promise.race and Promise.any?
All four take an iterable of promises that are already running. Promise.all fulfills with an array of results in input order, but rejects as soon as any one rejects; use it when every result is required. Promise.allSettled never rejects: it waits for all of them and returns objects with status 'fulfilled' or 'rejected', which suits batch jobs where partial failure is acceptable. Promise.race settles with whichever promise settles first, success or failure, and is often used for timeouts. Promise.any fulfills with the first success and rejects with an AggregateError only if all of them fail. None of them cancels the operations that lose.
24. Why doesn't an async callback inside forEach wait, and how do you fix it?
forEach calls the callback for each item and ignores what it returns. An async callback returns a promise, so forEach starts every operation, throws the promises away and finishes immediately: code after the loop runs before any save completes, and rejections become unhandled. There are two fixes. Use for...of with await when operations must run one after another, for example when order matters or the downstream system cannot take parallel load. Use map to collect the promises and await Promise.all when they are independent; this is much faster, but it starts everything at once, so limit concurrency for large arrays.
const save = (u) => new Promise((resolve) => setTimeout(() => resolve(u), 100));
async function saveSequential(users) {
for (const u of users) await save(u); // one at a time
}
async function saveParallel(users) {
await Promise.all(users.map((u) => save(u))); // all at once
}
saveParallel([1, 2, 3]).then(() => console.log('All saved')); 25. Write a function that runs async tasks with a maximum concurrency limit.
The idea is a small worker pool: start limit workers, and let each worker keep taking the next task index from a shared counter until none remain. Because JavaScript runs one piece of code at a time, reading and incrementing the shared index is safe without locks. Results are stored by index, so the output order matches the input order. This pattern appears in interviews and in real jobs, such as sending 10,000 SMS messages or calling a rate-limited partner API with at most five calls in flight. In this version one failed task rejects the whole call; use allSettled-style result objects if you need every outcome.
async function runWithLimit(tasks, limit) {
const results = new Array(tasks.length);
let next = 0;
async function worker() {
while (next < tasks.length) {
const i = next++;
results[i] = await tasks[i]();
}
}
await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));
return results;
}
const tasks = [1, 2, 3, 4, 5].map((n) => () => Promise.resolve(n * 10));
runWithLimit(tasks, 2).then(console.log); // [10, 20, 30, 40, 50] 26. What is backpressure in streams, and how does Node.js handle it?
Backpressure is the situation where a writable destination cannot consume data as fast as the readable source produces it, for example reading from a fast disk and uploading over a slow network. Without control, the pending data piles up in memory until the process runs out of it. In Node, writable.write() returns false once the internal buffer goes past highWaterMark; the producer should stop writing and resume when the 'drain' event fires. pipe() and stream.pipeline() do this automatically by pausing and resuming the source. Problems usually appear in hand-written loops that ignore the return value of write().
27. How would you compress a large file in Node.js without loading it into memory?
Connect a read stream, a gzip transform and a write stream with pipeline from node:stream/promises. Data flows in chunks (64 KB by default for file read streams), so memory stays flat whether the file is 10 MB or 10 GB. pipeline is better than chaining .pipe() because it handles backpressure, forwards an error from any stage and destroys all the streams on failure, so file descriptors do not leak. With .pipe() you would need an error handler on every stream. The promise version lets you await completion and handle errors with try/catch.
const fs = require('node:fs');
const zlib = require('node:zlib');
const { pipeline } = require('node:stream/promises');
async function gzipFile(src) {
await pipeline(
fs.createReadStream(src),
zlib.createGzip(),
fs.createWriteStream(`${src}.gz`)
);
}
gzipFile('access.log').then(() => console.log('done'), console.error); 28. How does error-handling middleware work in Express, and what changed in Express 5?
An error handler is middleware with four parameters, (err, req, res, next). Express recognises it by that count, so you must declare all four even if next is unused, and you register it after the routes. When a middleware calls next(err) or throws synchronously, Express skips the remaining normal middleware and jumps to the error handlers. In Express 4, a rejected promise from an async handler was not caught, so you needed try/catch or a wrapper that called next(err). Express 5 passes rejected promises from handlers and middleware to the error handler automatically. Send the client a generic message and log the details.
const express = require('express');
const app = express();
app.get('/orders/:id', async (req) => {
throw new Error(`Lookup failed for order ${req.params.id}`); // Express 5 catches this
});
app.use((err, req, res, next) => {
console.error(err.message);
res.status(err.status ?? 500).json({ error: 'Something went wrong' });
});
app.listen(3000); 29. How would you structure a medium-sized Express application?
Separate it into layers. Routes map URLs and methods to controllers and attach middleware such as authentication and validation. Controllers translate between HTTP and business logic: read the input, call a service, choose the status code. Services hold the business rules and know nothing about req or res, so cron jobs and queue workers can reuse them and they are easy to unit test. A data-access layer holds the SQL or ORM calls. Keep config, logging and shared middleware in their own modules. Create the app in one file and call listen() in another, so tests can import the app without opening a port. Group files by feature, such as users and orders, as the project grows.
30. What is the difference between operational errors and programmer errors, and how should each be handled?
Operational errors are expected runtime problems in a correct program: a database timeout, a missing file, invalid user input or a third-party API returning 503. You handle them: retry, return a 4xx or 503 response, or fall back to cached data. Programmer errors are bugs, such as reading a property of undefined or passing the wrong argument type. You cannot recover from them safely because the process state may be inconsistent. The usual practice is to log them, let the process exit and have a supervisor such as systemd, PM2 or Kubernetes restart it. That is why an uncaughtException handler should log and exit rather than keep serving traffic.
31. What is the difference between the cluster module and worker threads?
The cluster module forks several Node processes, usually one per CPU core, that share one server port; the primary process hands out connections, round-robin by default on every platform except Windows. Each worker has its own memory and event loop, so a crash in one does not kill the others, and cluster scales request handling. Worker threads run JavaScript in parallel threads inside one process, each with its own V8 isolate and event loop. They communicate by message passing and can share memory through SharedArrayBuffer. Use worker threads for CPU-heavy tasks such as image processing; use cluster, PM2 or more container replicas to scale an I/O-bound server.
32. When would you use spawn, exec, execFile or fork from node:child_process?
All four start a child process. spawn runs a command and streams its stdout and stderr, so it suits long-running processes or large output, such as running ffmpeg. exec runs a command inside a shell and buffers the whole output into a callback; it is convenient for short commands, but it has a maxBuffer limit and is dangerous with user input because the shell interprets it. execFile runs a program directly without a shell, which is safer when arguments come from users. fork is a special form of spawn for Node scripts that adds an IPC channel, so parent and child exchange messages with send() and the 'message' event.
33. How does JWT-based authentication work in a Node.js API, and what are its pitfalls?
After login the server issues a JSON Web Token: a base64url-encoded header and payload, such as the user ID and role, plus a signature made with a secret or private key. The client sends it in the Authorization: Bearer header, and middleware verifies the signature and expiry on every request without a session lookup. Pitfalls: the payload is only encoded, not encrypted, so never put sensitive data in it; a token cannot easily be revoked, so keep access tokens short-lived (5 to 15 minutes) and use refresh tokens tracked on the server; always pin the allowed algorithm when verifying; and in browsers prefer httpOnly cookies over localStorage to reduce the impact of XSS.
34. What practical steps do you take to secure an Express API?
Validate every input against a schema and reject unknown fields. Use parameterized queries or an ORM to prevent SQL injection. Set security headers with helmet, restrict CORS to known origins and rate-limit login, OTP and other expensive routes. Hash passwords with bcrypt or argon2. Never return stack traces to clients, and keep secrets in environment variables or a secrets manager. Limit body size, for example express.json({ limit: '100kb' }). Run npm audit in CI, keep dependencies updated and commit the lock file. Finally, check authorization on every object you load, not just authentication, so one user cannot read another user's records by changing an ID.
35. How do you find and fix a memory leak in a Node.js application?
First confirm it: watch process.memoryUsage() or container metrics and look for heap usage that keeps climbing under steady load and never drops back after garbage collection. Then take heap snapshots, either by starting with --inspect and using Chrome DevTools or by calling v8.writeHeapSnapshot(). Take two or three snapshots some minutes apart and compare them to see which object types keep growing and what is holding them. Common causes are unbounded in-memory caches or arrays, event listeners added on every request and never removed, intervals never cleared, and global maps keyed by request or user. Fix by bounding caches with a size limit, removing listeners and clearing timers.
36. Can you use an ES module from CommonJS code, and vice versa?
ES modules can import CommonJS modules directly: module.exports becomes the default export, and Node tries to detect named exports too. The other direction used to need dynamic import(), which returns a promise and works in any CommonJS file. Since Node 22.12 and 20.19, require() can also load an ES module synchronously, as long as that module and its imports do not use top-level await; otherwise it throws ERR_REQUIRE_ASYNC_MODULE. This makes ESM-only packages much easier to use from older code. For libraries, the exports field in package.json can point require and import to different entry files.
// app.cjs, using math.mjs which exports add()
const { add } = require('./math.mjs'); // Node 22.12+ and 20.19+
console.log(add(1, 1));
import('./math.mjs').then((m) => console.log(m.add(2, 3))); // any version 37. How do you implement pagination in a REST API, and which approach scales better?
Offset pagination takes page and limit, mapped to LIMIT and OFFSET in SQL. It is simple and lets users jump to page 50, but the database still reads and discards all the skipped rows, so deep pages get slow, and rows inserted between requests cause duplicates or gaps. Cursor, or keyset, pagination returns an opaque cursor built from the last row's sort values, such as created_at and id, and the next query uses WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20. With a matching composite index it stays fast at any depth and stays stable under inserts, which suits feeds and infinite scroll. Always cap limit on the server.
38. How do you test an HTTP endpoint in Node.js?
Write integration tests that start the server on a random free port, send real HTTP requests and assert on the status and body. Export the app separately from the code that calls listen(), so a test can start it with listen(0) and close it afterwards. supertest with Jest or Vitest is a popular combination, but the built-in node:test runner plus the global fetch works without extra packages. Use a separate test database or a fake data layer, reset state between tests, and cover failure cases such as 400 for invalid input and 401 without a token, not just the happy path.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const app = http.createServer((req, res) => res.end(JSON.stringify({ ok: true })));
test('GET /health returns ok', async (t) => {
await new Promise((resolve) => app.listen(0, resolve));
t.after(() => app.close());
const res = await fetch(`http://localhost:${app.address().port}/health`);
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), { ok: true });
}); 39. What is the libuv thread pool, and when would you change UV_THREADPOOL_SIZE?
libuv keeps a pool of threads, four by default, for work that has no good asynchronous operating-system interface: most fs calls, dns.lookup, and the async versions of CPU-heavy crypto and zlib functions such as crypto.pbkdf2 and zlib.gzip. Network sockets do not use it. If a service hashes many passwords with pbkdf2 or native bcrypt bindings while also reading files, those jobs queue behind each other and latency rises even though CPU cores sit idle. Raising UV_THREADPOOL_SIZE, for example to the number of cores, can help. Set it as an environment variable before the process starts, because the pool is created on first use, and measure before and after.
40. How do you implement a graceful shutdown in a Node.js server?
Listen for SIGTERM, which Kubernetes, Docker and systemd send, and SIGINT. On the signal, stop accepting new connections with server.close(), let in-flight requests finish, close database pools and queue consumers, and exit with code 0. Add a hard timeout so one stuck connection cannot block shutdown forever. Since Node 19, server.close() also closes idle keep-alive connections, and server.closeAllConnections() is there for the timeout path. In Kubernetes, the readiness probe should start failing during shutdown so the load balancer stops sending new traffic.
const http = require('node:http');
const server = http.createServer((req, res) => res.end('ok')).listen(3000);
function shutdown(signal) {
console.log(`${signal} received, closing`);
server.close(() => {
// close DB pools and queue consumers here
process.exit(0);
});
setTimeout(() => process.exit(1), 10_000).unref();
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown); Node.js interview questions: Hard questions (experienced)
41. An API slows down for every user whenever one report endpoint is called. How do you confirm event-loop blocking and fix it?
A synchronous CPU-heavy task, such as building a large report, JSON.parse or JSON.stringify of a huge payload, a sync crypto call or a bad regex, blocks every other request while it runs. Confirm it by measuring event-loop delay with perf_hooks.monitorEventLoopDelay or an APM metric and correlating spikes with that endpoint; a CPU profile from --cpu-prof or DevTools will show the hot function. Fixes, in order of preference: do less work (paginate, stream the output, precompute in a background job), move the work to a worker thread pool, or split a long loop into chunks that yield with setImmediate. Then alert on p99 event-loop delay.
const { monitorEventLoopDelay } = require('node:perf_hooks');
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => {
const p99 = h.percentile(99) / 1e6; // nanoseconds to ms
if (p99 > 100) console.warn(`Event loop p99 delay ${p99.toFixed(1)} ms`);
h.reset();
}, 10_000).unref(); 42. Why can recursive process.nextTick calls starve I/O while recursive setImmediate calls cannot?
The nextTick queue is drained completely before the event loop may continue, and callbacks added while it is being drained run in the same pass. So a function that keeps rescheduling itself with process.nextTick, or an endless chain of promise microtasks, never lets the loop reach the poll phase: timers and I/O callbacks never run, and the server stops responding while CPU sits at 100%. setImmediate callbacks scheduled during the check phase are deferred to the next iteration, so the loop still visits timers and poll in between. Use nextTick only for small work that must happen right after the current function, such as emitting an event after a constructor returns; use setImmediate to yield in long loops.
43. Show how you would offload a CPU-heavy calculation to a worker thread.
Create a Worker with a script, pass input through workerData or postMessage, and listen for the result message. The worker runs in its own thread with its own V8 isolate and event loop, so the main thread keeps serving requests while the calculation runs. Starting a worker costs time and memory, so a real service keeps a fixed pool, sized near the core count, and queues tasks instead of creating one worker per request. Data is copied with the structured clone algorithm; large ArrayBuffers can be transferred instead of copied, and SharedArrayBuffer with Atomics allows shared memory.
const { Worker, isMainThread, parentPort, workerData } = require('node:worker_threads');
const fib = (n) => (n < 2 ? n : fib(n - 1) + fib(n - 2));
if (isMainThread) {
const worker = new Worker(__filename, { workerData: 40 });
worker.on('message', (result) => console.log('fib(40) =', result));
worker.on('error', console.error);
console.log('Main thread is still free');
} else {
parentPort.postMessage(fib(workerData));
} 44. How would you scale a Node.js API from one server to handle ten times the traffic?
First measure where the limit is: CPU, event-loop delay, the database or an external API. Then make the app stateless: move sessions, rate-limit counters and caches to Redis and uploaded files to object storage, so any instance can serve any request. Run several instances, one process per core with cluster or PM2 on virtual machines, or one process per container with more replicas in Kubernetes, behind a load balancer with health checks. The database usually becomes the next bottleneck, so add indexes, connection pooling, read replicas and caching of hot reads. Move slow work such as emails and PDF generation to a queue. WebSockets need sticky sessions or a shared pub/sub adapter.
45. Write a Transform stream that converts text to uppercase, and explain how errors and backpressure are handled.
A Transform implements transform(chunk, encoding, callback): pass the transformed data as the second argument of the callback (or push it) and call the callback exactly once per chunk; calling it with an error destroys the stream. Backpressure is automatic: when the readable side's buffer is over highWaterMark, Node stops calling transform until the consumer reads more. When pipeline() connects the stages, an error in any one of them destroys all of them and rejects the promise. One edge case: a multi-byte UTF-8 character can be split across two raw chunks, so set an encoding on the source, as below, or use a StringDecoder before converting bytes to text.
const { Transform } = require('node:stream');
const { pipeline } = require('node:stream/promises');
const upper = new Transform({
transform(chunk, encoding, callback) {
callback(null, chunk.toString().toUpperCase());
},
});
process.stdin.setEncoding('utf8');
pipeline(process.stdin, upper, process.stdout).catch((err) => {
console.error('Pipeline failed:', err);
process.exitCode = 1;
}); 46. What is the advantage of consuming streams with for await...of, and what are the pitfalls?
Readable streams are async iterables, so for await...of reads one chunk at a time, and an await inside the loop body applies backpressure naturally: the stream is not read further until your code finishes with the current chunk. Error handling is simple too, because a stream error is thrown inside the loop and can be caught with try/catch, and breaking out of the loop destroys the stream. Pitfalls: processing is strictly sequential, so slow async work per chunk limits throughput; chunks are arbitrary byte ranges, not lines, so use readline for line-based files; and you should not mix for await with 'data' listeners on the same stream.
const fs = require('node:fs');
const readline = require('node:readline');
async function countErrors(file) {
const lines = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
let count = 0;
for await (const line of lines) {
if (line.includes('ERROR')) count++;
}
return count;
}
countErrors('app.log').then((n) => console.log(`${n} error lines`), console.error); 47. How would you diagnose high p99 latency in a production Node.js service?
Break the request time down before guessing. Use an APM tool or OpenTelemetry tracing to see how much time goes to your own code, database queries and outbound HTTP calls. Check event-loop delay and CPU: if the loop is lagging, take a CPU profile with --cpu-prof or the inspector and read the flame graph for hot synchronous functions. If the loop is healthy, look for waiting: a database or HTTP connection pool that is too small, slow queries without indexes, no keep-alive on outbound calls, or a slow dependency without a timeout. Also check garbage-collection pauses and memory pressure. Fix the biggest contributor first, deploy, and compare p99 again.
48. How does garbage collection work in V8, and how can it affect a Node.js server?
V8 uses generational garbage collection, based on the observation that most objects die young. New objects go into the young generation, which a fast scavenger collects often by copying live objects between two semi-spaces; objects that survive a couple of collections are promoted to the old generation. The old generation is collected with mark-sweep-compact, and most of the marking runs incrementally and concurrently on helper threads to keep pauses short. Trouble starts when a service allocates heavily or holds a large heap: GC runs more often, pauses add to p99 latency, and near the heap limit the process spends most of its time collecting before failing with "heap out of memory". You can raise the limit with --max-old-space-size, but reducing allocation fixes the cause.
49. How do you attach a request ID to every log line without passing it through every function?
Use AsyncLocalStorage from node:async_hooks. Call storage.run(context, callback) at the start of each request; any code called from there, including code that resumes later after an await, a timer or a callback, can read that context with storage.getStore(). This gives you per-request context, much like a thread-local in Java, without threading a parameter through every service and repository. APM agents and OpenTelemetry use the same mechanism for tracing. Create a fresh store object per request and keep it small. Context can be lost if a library queues callbacks in its own pool, so test logging around such libraries.
const { AsyncLocalStorage } = require('node:async_hooks');
const { randomUUID } = require('node:crypto');
const http = require('node:http');
const als = new AsyncLocalStorage();
const log = (msg) => console.log(`[${als.getStore()?.id ?? '-'}] ${msg}`);
http.createServer((req, res) => {
als.run({ id: randomUUID() }, async () => {
log(`start ${req.url}`);
await new Promise((r) => setTimeout(r, 50));
log('done');
res.end('ok');
});
}).listen(3000); 50. What happens if an async function used as an EventEmitter listener throws, and how do you handle it?
emit() calls listeners synchronously and ignores their return values. An async listener returns a promise, so when it rejects nobody is waiting: the emitter's 'error' event does not fire and the rejection becomes an unhandled rejection, which crashes the process by default since Node 15. One option is a try/catch around the body of every async listener. Another is to create the emitter with { captureRejections: true }, which makes Node route a rejected listener promise to the 'error' event. This matters in event-driven code, for example an 'order' handler that sends a confirmation email and fails because the mail provider is down.
const { EventEmitter } = require('node:events');
const bus = new EventEmitter({ captureRejections: true });
bus.on('order', async (order) => {
throw new Error(`Email failed for order ${order.id}`);
});
bus.on('error', (err) => console.error('Handled:', err.message));
bus.emit('order', { id: 7 }); 51. What is prototype pollution, and how do you prevent it in a Node.js API?
Prototype pollution happens when code copies attacker-controlled keys into an object, typically in a deep merge, a query-string parser or a path-based setter, and one key is __proto__ or constructor.prototype. Instead of creating a normal property, the code writes to Object.prototype, so every object in the process gains the attacker's property. That can bypass checks like if (user.isAdmin) or, through template engines and child_process options, even lead to code execution. Prevent it by validating input against a strict schema, skipping __proto__, constructor and prototype keys in merge code, using Map or Object.create(null) for dictionaries keyed by user input, and keeping merge libraries updated.
function unsafeMerge(target, src) {
for (const key in src) {
if (typeof src[key] === 'object') {
target[key] = unsafeMerge(target[key] ?? {}, src[key]);
} else target[key] = src[key];
}
return target;
}
unsafeMerge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
console.log({}.isAdmin); // true: every object is now "admin" 52. What is ReDoS, and why is it especially dangerous in Node.js?
Regular expression denial of service happens when a pattern with nested or overlapping quantifiers, such as /^(a+)+$/, meets an input that almost matches. The backtracking engine then tries an exponential number of paths, so a string of around 30 characters can keep the CPU busy for seconds. In Node this is worse than in thread-per-request servers because the regex runs on the main thread, so one malicious request freezes every user. Prevent it by limiting input length before matching, avoiding nested quantifiers, checking patterns with a ReDoS linter, using a linear-time engine such as RE2 for user-supplied patterns, and never building a regex from raw user input.
const evil = /^(a+)+$/;
const input = 'a'.repeat(25) + '!';
console.time('regex');
evil.test(input); // blocks for seconds; each extra 'a' roughly doubles it
console.timeEnd('regex'); 53. A route serves files using a filename from the query string. What can go wrong, and how do you make it safe?
The main risk is path traversal: a value such as ../../etc/passwd, or an encoded variant, escapes the intended folder and exposes source code, .env files or system files. path.join() alone does not help, because it resolves the .. segments. Resolve the requested path against an absolute base directory, then check that the result still starts with the base directory plus a path separator, and reject it otherwise. Better still, avoid user-controlled paths: store files under generated IDs and look up the real path in the database after checking that this user may access the file. Also set a correct Content-Type and Content-Disposition on the response.
const path = require('node:path');
const BASE = path.resolve('uploads');
function safePath(userInput) {
const full = path.resolve(BASE, userInput);
if (!full.startsWith(BASE + path.sep)) {
throw Object.assign(new Error('Invalid path'), { status: 400 });
}
return full;
}
console.log(safePath('invoice-42.pdf'));
// safePath('../.env') throws 54. How do you make a payment or order-creation API safe against duplicate requests?
Use idempotency keys. The client generates a unique key, such as a UUID, for each logical operation and sends it in a header like Idempotency-Key, reusing the same key when it retries after a timeout. The server stores the key with a hash of the request and the resulting response, in a table with a unique constraint on the key. On a repeat it returns the stored response instead of charging again; if the same key arrives with a different body it returns an error such as 422; if the first request is still processing it returns 409 so the client retries later. Expire keys after a day or so, and make webhook handlers from the payment gateway idempotent too, keyed by the gateway's event or payment ID.
55. How would you design rate limiting for a Node.js API that runs on several instances?
Counters must live in shared storage, usually Redis, because in-process counters let a client multiply its limit by the number of instances. A fixed window, an INCR on a key per user per minute with an expiry, is simple but allows bursts at the window boundary. A sliding window or token bucket is smoother: a token bucket refills at a steady rate, allows short bursts up to its capacity, and can be updated atomically in a Lua script. Key by user ID or API key on authenticated routes and by IP and phone number on login and OTP routes. Return 429 with a Retry-After header, and give costly endpoints such as OTP sending much stricter limits.
56. How would you move slow work such as emails, invoices and report generation out of the request path?
Put a job on a queue and respond immediately, usually with 202 Accepted and a job ID the client can poll, or notify it later by webhook or push. In Node a common choice is BullMQ on Redis; RabbitMQ, SQS or Kafka fit larger systems. Workers run as separate processes, so they scale and deploy independently of the API. Make jobs idempotent, because queues deliver at least once; configure retries with exponential backoff; send jobs that keep failing to a dead-letter queue for inspection; and set a timeout per job. To avoid a job for a database change that later rolls back, write an outbox row in the same transaction and publish from it.
57. How do you publish a package that supports both require and import, and what is the dual package hazard?
Use the exports field in package.json with conditions: "import" points to the ESM build, "require" to the CommonJS build, and "types" for TypeScript goes first. exports also hides internal files, because consumers can import only the paths you list. The dual package hazard is that one application can load both builds, for example when one dependency requires the package and another imports it. You then have two module instances: two copies of internal state, singletons that are no longer single and instanceof checks that fail across them. Mitigate it by keeping shared state in one file both builds use or, now that require(esm) works in Node 20.19 and 22.12+, by shipping ESM only.
{
"name": "@acme/money",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
} 58. How do you unit test code that depends on a database or an external API?
Keep unit tests off the network. Design code so dependencies are injected, for example by passing a repository or HTTP client into a service, and pass a fake in the test. Node's test runner gives you mock.fn() and mock.method(), which record calls and arguments; mocks created through the test context, t.mock, are restored automatically when the test ends. Mock at the boundary, not your own internal functions, or tests break on every refactor. Keep a smaller set of integration tests against a real database, for example one started in Docker, to catch SQL and mapping bugs that mocks cannot.
const { test } = require('node:test');
const assert = require('node:assert/strict');
function makeService(repo) {
return { greet: async (id) => `Hello, ${(await repo.findUser(id)).name}` };
}
test('greets the user by name', async (t) => {
const repo = { findUser: t.mock.fn(async () => ({ name: 'Ravi' })) };
const service = makeService(repo);
assert.equal(await service.greet(1), 'Hello, Ravi');
assert.equal(repo.findUser.mock.callCount(), 1);
assert.deepEqual(repo.findUser.mock.calls[0].arguments, [1]);
}); 59. Why can outbound HTTP calls and database connections become a bottleneck, and how do pools and keep-alive help?
Every new TCP connection costs a handshake, and HTTPS adds a TLS handshake, often tens of milliseconds to a remote service. Reusing connections avoids that. Since Node 19 the default HTTP agent keeps connections alive, and the built-in fetch, based on undici, pools connections per origin, but older code may still create a fresh agent per request. For databases, create one shared pool per process at startup instead of a connection per request, and size it with the whole fleet in mind: 20 instances with a pool of 50 each means 1,000 connections, which can exhaust the database. Always set timeouts on outbound calls so a slow dependency cannot hold connections forever.
60. Walk through what happens inside Node.js from the moment an HTTP request arrives until your handler runs.
The client's TCP connection reaches the listening socket. libuv, using epoll on Linux, learns in the poll phase that the socket is ready, Node accepts the connection, and later reads the incoming bytes. Node's HTTP parser, llhttp, parses the request line and headers and creates the IncomingMessage and ServerResponse objects; the server then emits 'request', which calls your Express app. Middleware and your handler run synchronously on the main thread until they await I/O, such as a database query, and the loop serves other sockets meanwhile. When the query result arrives, the promise resolves, the handler continues, and the response is written back through the socket. Any CPU work on this path delays every other request.
Ready to apply?
There are 391 open jobs asking for Node.js on CoreHRx right now. Create a free profile once and apply with it.