A focused Node.js interview set for backend developers. The answers are structured for spoken practice: explain the architecture, give an example and mention production trade-offs.
Node.js is a JavaScript runtime outside the browser, built on V8 and extended with APIs for networking, files, processes, streams and operating-system access. It is commonly used for REST APIs, GraphQL services, realtime WebSocket servers, microservices, CLI tools, proxies and API gateways. Its main strength is non-blocking I/O: one process can handle many network connections efficiently. The trade-off is that long CPU-heavy calculations can block the event loop unless moved to workers, processes or another service.
JavaScript runs in V8, while Node.js APIs delegate asynchronous work to the operating system or to libuv's thread pool. The main JavaScript thread does not wait for an I/O operation to finish; it continues executing other code. When the operation completes, its callback or Promise continuation becomes ready for the event loop. This is how Node.js achieves high concurrency even though user JavaScript usually runs on one main thread.
V8 compiles and executes JavaScript and manages JavaScript objects and memory. libuv provides the cross-platform event loop, async I/O integration and a worker thread pool for operations such as some filesystem work, DNS and crypto. The event loop decides when completed operation callbacks should run. Node.js itself connects JavaScript APIs to native capabilities; V8 alone does not provide an HTTP server, filesystem access or process APIs.
A single Node.js process usually executes user JavaScript on one main thread, so two normal request handlers do not run JavaScript at the exact same time in that thread. However, the platform is not literally one thread: libuv has a thread pool, the operating system performs async I/O, V8 has garbage-collection work, and Node.js also supports worker_threads and child processes. The phrase mainly describes the default JavaScript execution model.
5How can Node.js handle many requests concurrently?
Node.js starts I/O operations and then returns to the event loop instead of blocking a thread per request. For example, 1,000 database calls can be in flight while the main thread continues accepting connections and processing ready callbacks. This works well for I/O-bound workloads such as HTTP calls, database queries, files and sockets. It works poorly when request handlers do heavy synchronous CPU work, because that blocks the event loop and delays every other request.
6What are the main phases of the Node.js event loop?
Node.js uses libuv scheduling phases including pending callbacks, idle/prepare, poll, check and close callbacks, together with timer processing. Poll handles many I/O callbacks, check runs setImmediate callbacks, and timers run setTimeout/setInterval callbacks once their delay thresholds and scheduling permit. In libuv 1.45 and Node.js 20+, timers are processed after poll in each iteration, with an initial compatibility timer pass before entering the loop. process.nextTick is a separate Node queue, while Promise reactions and queueMicrotask use V8's microtask queue; neither is a libuv phase. These queues run at checkpoints between callbacks. Recursively replenishing them can starve other work.
7How do synchronous, asynchronous and non-blocking operations differ?
Synchronous describes control flow: a call produces its result or throws before its caller continues. Asynchronous describes a result delivered through a later callback, Promise or other notification. Blocking describes waiting for work or a resource while the current thread cannot do other work; non-blocking APIs avoid that wait, for example by returning a would-block result or arranging notification. These are different dimensions: synchronous does not by itself mean an API waits for I/O readiness, and returning a Promise does not make heavy synchronous computation non-blocking. In Node.js, fs.readFileSync is a blocking synchronous API; fs.promises.readFile delivers its result asynchronously while the main thread can process other work. Prefer suitable asynchronous I/O APIs in request paths.
Event loop starvation occurs when synchronous JavaScript or continually replenished checkpoint queues prevent timely processing of I/O callbacks, timers and requests. Causes include heavy computations, large JSON operations, recursively scheduled microtasks and excessive process.nextTick use. Limit input sizes, profile expensive work and move CPU-heavy jobs to worker_threads or another service. If splitting work into chunks, yield to a later event-loop turn, for example with setImmediate; repeatedly awaiting already-resolved Promises or scheduling queueMicrotask may still starve I/O. Avoid blocking synchronous APIs in hot request paths.
A callback is a function supplied to another operation to be invoked by that operation. It may run synchronously, as with Array.map, or asynchronously, as with fs.readFile. Classic Node.js asynchronous callbacks commonly use the error-first convention: callback(error, result). Callback hell means deeply nested dependent callbacks that obscure control flow and error handling. Named functions, smaller operations, Promises and async/await can improve readability. Start independent operations together where appropriate, for example with Promise.all, while respecting concurrency limits and handling errors.
import fs from 'node:fs';
fs.readFile('file.txt', 'utf8', (err, data) => {
if (err) {
console.error(err);
return;
}
console.log(data);
});
10What is a Promise and how does async/await work?
A Promise represents the future result of an asynchronous operation and can be pending, fulfilled or rejected. An async function always returns a Promise. await pauses only the current async function until the Promise settles; it does not block the whole Node.js process. A good answer should mention error handling with try/catch, avoiding floating Promises and using Promise.all for independent work so operations do not run unnecessarily sequentially.
11How do process.nextTick, queueMicrotask, setImmediate and setTimeout differ?
process.nextTick schedules work in Node's next-tick queue; queueMicrotask uses V8's microtask queue, also used by Promise reactions. At a normal CommonJS top-level checkpoint, nextTick callbacks run before queued Promise/queueMicrotask callbacks. At ES-module top level, evaluation already occurs in a microtask context, so queued microtasks can run before nextTick. This is context-dependent, not a universal priority rule that interrupts executing JavaScript. setImmediate runs in check. setTimeout(fn, 0) waits for a minimum timer threshold and is not immediate. Their relative order depends on where they are scheduled; when both are scheduled inside an I/O callback, setImmediate runs first. Prefer queueMicrotask for portable microtask deferral unless nextTick-specific behavior is needed.
// CommonJS file (.cjs): nextTick precedes promise at this top level.
// ES module file (.mjs): promise precedes nextTick at this top level.
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
// timeout vs immediate is context-dependent here.
Use try/catch for synchronous throws. For Promise-based operations, await inside try/catch or attach a rejection handler; an outer synchronous try/catch does not catch a later callback's throw. Error-first callbacks must check the error argument, and EventEmitter error events need appropriate handlers. Servers should centralize request error handling, log useful context and avoid exposing internal details to clients. After an uncaughtException, do not assume the process can safely continue: perform only necessary synchronous emergency cleanup, exit and let a supervisor restart it. A planned graceful shutdown is useful for normal termination, but arbitrary asynchronous recovery after an uncaught exception is not a reliable strategy.
A module is an isolated unit of code with an explicit exported interface. Node.js supports built-in modules such as node:fs and node:http, local project modules, npm packages, CommonJS modules and ECMAScript modules. Modules make responsibility boundaries clearer and allow reuse. The production trade-off is coupling: if modules import each other too deeply or hide side effects at import time, testing and startup behavior become harder to reason about.
// math.js
export function add(a, b) {
return a + b;
}
// app.js
import { add } from './math.js';
14How do CommonJS require and ES Modules import differ?
CommonJS uses require and module.exports; ES modules use import and export. require normally loads synchronously and may be called conditionally. Static import declarations expose the dependency graph for analysis; dynamic import() returns a Promise. Modern Node.js can also require an ES-module graph that has no top-level await, subject to the supported interoperation rules, so ESM is not synonymous with always-asynchronous evaluation. The .cjs/.mjs extensions, package.json type and applicable syntax-detection rules determine the module mode. ESM supports top-level await. CommonJS provides __dirname/__filename; current Node.js ESM provides import.meta.dirname/filename for file modules, while import.meta.url is useful for URL-based resolution and older-compatible approaches.
// File: math.cjs (CommonJS)
function calculate(a, b) { return a + b; }
module.exports = { calculate };
// Separate file: math.mjs (ES module)
export function calculate(a, b) { return a + b; }
// These are two separate files, not one combined script.
15What are npm, package.json and package-lock.json?
npm is the package-management CLI. package.json describes metadata, scripts and direct dependency requirements, often using version ranges. package-lock.json records a resolved dependency tree with package versions and integrity information. Commit the lock file for an application. npm ci is useful in CI because it installs from the lock file, removes an existing node_modules directory and fails when package.json and the lock disagree. A lock file reduces dependency drift; it does not make operating systems, native builds, lifecycle scripts, Node versions or installation options identical. Reproducible builds also require a controlled toolchain and environment.
16How should configuration and environment variables be managed?
Read environment variables through process.env: present values are strings, while a missing property is undefined. Validate required values and convert them deliberately at startup; converting PORT with Number alone does not reject an empty string, NaN or an out-of-range port. Fail clearly when required configuration is invalid. Keep real secrets out of source control and supply production secrets through the deployment platform or a secret manager; a secret-free example configuration may be committed. NODE_ENV is an application convention, not a trusted security boundary.
const rawPort = process.env.PORT ?? '3000';
if (!/^\d+$/.test(rawPort)) throw new Error('PORT must be an integer');
const port = Number(rawPort);
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
throw new Error('PORT must be between 1 and 65535');
}
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl?.trim()) throw new Error('DATABASE_URL is required');
EventEmitter implements a publish-subscribe pattern inside a Node.js process. on registers a listener, once registers a listener for one event, off removes a listener and emit synchronously calls listeners in registration order. The synchronous nature is important: a slow listener blocks the emitter. The error event is special because an unhandled error event can crash the process. Long-lived systems should also remove unused listeners to avoid memory leaks.
import { EventEmitter } from 'node:events';
const emitter = new EventEmitter();
emitter.on('orderCreated', order => {
console.log(`Order ${order.id} created`);
});
emitter.emit('orderCreated', { id: 42 });
Buffer represents a fixed-size sequence of bytes. It is used for files, TCP sockets, HTTP bodies, images, cryptography and binary protocols. Buffer.from creates a buffer from existing data, Buffer.alloc creates zero-filled memory, and Buffer.allocUnsafe is faster but may contain old memory until overwritten. That makes allocUnsafe appropriate only when the code immediately fills the buffer and never exposes stale data.
const buffer = Buffer.from('Hello', 'utf8');
console.log(buffer.toString('utf8'));
const safe = Buffer.alloc(1024);
// Prefer alloc for general use. Never expose allocUnsafe bytes before filling them.
const fast = Buffer.allocUnsafe(1024);
fast.fill(0); // All bytes initialized before use.
Streams process data in chunks instead of loading everything into memory. The main types are Readable, Writable, Duplex and Transform. Files, HTTP request and response objects, TCP sockets and gzip streams are common examples. Streams are valuable for large files and network traffic because they reduce memory usage and latency. A production answer should mention that stream errors must be handled correctly across the whole chain.
20What is backpressure and why is pipeline useful?
Backpressure happens when a data source produces chunks faster than the destination can consume them. Without backpressure, memory can grow until the process slows down or crashes. Streams and pipe can coordinate flow, but pipeline from node:stream/promises is safer for multi-step chains because it propagates errors, closes related streams and returns a Promise. It is a good default for file compression, uploads, downloads and stream transformations.
import fs from 'node:fs';
import zlib from 'node:zlib';
import { pipeline } from 'node:stream/promises';
await pipeline(
fs.createReadStream('input.txt'),
zlib.createGzip(),
fs.createWriteStream('input.txt.gz')
);