← All interview questions

Top Node.js Interview Questions & Answers

Node.js interviews focus on its non-blocking, event-driven model and how that shapes API and backend design. These are the most commonly asked questions across backend interviews.

Take Practice Interview now
Speak your answers out loud and get a scored report in minutes — free trial included.
Start Free →

1. Why is Node.js non-blocking, and what does that mean in practice?

Node.js runs JavaScript on a single main thread but delegates I/O operations (file reads, network calls, DB queries) to the underlying system (via libuv's thread pool or OS-level async APIs) instead of waiting for them synchronously. The main thread stays free to handle other work while I/O is in flight, and a callback/Promise resolves once the operation completes — this makes Node efficient for I/O-heavy workloads with many concurrent connections, though CPU-bound work still blocks the single thread.

2. What is the event loop in Node.js?

The event loop is the mechanism that lets Node handle async callbacks despite being single-threaded. It cycles through phases (timers, pending callbacks, poll for I/O events, check, close callbacks), and after each phase processes any queued microtasks (Promise callbacks, process.nextTick). Understanding the phase order explains behaviors like why setImmediate and setTimeout(fn, 0) can fire in different orders depending on context.

3. What is middleware in Express, and how does the request/response cycle flow through it?

Middleware are functions with access to (req, res, next) that execute in sequence for each incoming request, each able to modify the request/response, end the cycle, or call next() to pass control to the following middleware. This is how cross-cutting concerns like authentication, logging, body parsing, and error handling get composed onto specific routes or the whole app without duplicating logic in every route handler.

4. How do you handle errors in async code in Node.js?

For Promise-based/async-await code, wrap awaited calls in try/catch, or attach .catch() to Promise chains — unhandled rejections should be avoided since Node now terminates the process by default when a rejection goes unhandled (Node 15+), rather than just printing a warning like older versions did. In Express 4, errors thrown inside an async route handler don't automatically reach the centralized error-handling middleware — you have to explicitly call next(err) or wrap the handler in a helper (like express-async-handler) that catches the rejection and forwards it for you. Express 5 changed this: if a route handler returns a rejected promise, Express now automatically calls next(err) on your behalf, removing a very common source of unhandled-rejection bugs in older codebases. It's worth checking which major version of Express a codebase is on before assuming async handlers are 'safe' by default.

5. What is the difference between process.nextTick, setImmediate, and setTimeout?

process.nextTick queues a callback to run immediately after the current operation completes, before the event loop continues — it has the highest priority among these. setImmediate queues a callback to run in the 'check' phase of the current event loop iteration. setTimeout(fn, 0) queues a callback for the 'timers' phase, subject to a minimum delay — the exact ordering relative to setImmediate can vary depending on whether it's called from the main module or within an I/O callback.

6. How would you scale a Node.js application across multiple CPU cores?

Since Node runs JavaScript on a single thread, use the built-in cluster module (or a process manager like PM2) to fork multiple worker processes — typically one per CPU core — each running its own event loop, with incoming connections load-balanced across them by the master process. For stateful data that needs to be shared across workers (like sessions or rate-limit counters), an external store like Redis is needed since each worker process has separate memory.

7. What is the difference between CommonJS (require) and ES Modules (import) in Node.js?

CommonJS modules are loaded synchronously and resolved at runtime — require() calls can be conditional/dynamic anywhere in the code, and module.exports defines what's exported. ES Modules use static import/export syntax resolved at parse time (enabling tree-shaking), and are loaded asynchronously under the hood. Node supports both, distinguished by the .mjs extension or "type": "module" in package.json, though interop between the two systems has some rough edges (e.g., importing a CommonJS module as default vs. named exports).

8. What are streams in Node.js, and what is backpressure?

Streams are Node's abstraction for handling data incrementally rather than loading it all into memory at once — there are four types: Readable (a source, like fs.createReadStream), Writable (a destination, like a file or HTTP response), Duplex (both, like a TCP socket), and Transform (a duplex stream that modifies data as it passes through, like zlib.createGzip). Backpressure is what happens when a writable destination can't consume data as fast as a readable source produces it; if you ignore it and keep calling write() without checking its return value, buffered data piles up in memory and can crash the process. The fix is to respect the boolean returned by write() — when it returns false, pause writing until the stream emits a 'drain' event — or, more commonly, just use .pipe(), which handles backpressure automatically by pausing and resuming the source stream for you.

9. What is a Buffer in Node.js and when would you use one?

A Buffer is a fixed-length chunk of raw binary data allocated outside the V8 heap, used whenever Node deals with I/O that isn't naturally a JS string — reading a file in binary mode, receiving TCP socket data, or handling image/file uploads, for example. Buffers predate the JS TypedArray spec but are now implemented as a subclass of Uint8Array, so they support similar indexing and slicing while adding Node-specific helpers like Buffer.from(), buf.toString(encoding), and buf.write(). Because they sit outside normal heap memory and aren't automatically zeroed, Buffer.allocUnsafe() is faster than Buffer.alloc() but can leak old memory contents if you don't overwrite the whole buffer before reading from it, so alloc() (which zero-fills) is the safer default. In practice you mostly encounter Buffers implicitly, e.g., as the chunks emitted by a readable stream, and convert them to strings via .toString('utf8') once you're done with the binary layer.

10. What's the difference between what's in package.json's version ranges and what's in package-lock.json, and why do both matter?

package.json specifies acceptable version ranges for dependencies using semantic versioning operators — ^1.2.3 allows minor and patch updates (up to but not including 2.0.0), ~1.2.3 allows only patch updates, and an exact version pins it completely. package-lock.json records the exact resolved version (and the full dependency tree, including transitive dependencies) that was actually installed at some point, down to the integrity hash, so that running npm install on another machine or in CI reproduces byte-for-byte the same node_modules tree rather than whatever the loosest version range happens to resolve to that day. Without the lockfile, two developers (or a dev machine and a CI server) running npm install at different times could get different transitive dependency versions even with identical package.json files, which is a classic source of 'works on my machine' bugs. The lockfile should always be committed to version control, and npm ci (rather than npm install) should be used in CI/deploy pipelines since it installs strictly from the lockfile and fails if it's out of sync with package.json.

11. How do you manage configuration and secrets across different environments in a Node app?

The standard approach is to read configuration from process.env, keeping environment-specific values (API keys, DB connection strings, feature flags) out of source code entirely; locally, a package like dotenv loads a .env file into process.env for convenience, while in staging/production those variables are injected directly by the hosting platform, container orchestrator, or secrets manager (e.g., AWS Secrets Manager, Vault) rather than living in a file at all. NODE_ENV is the conventional flag (development, test, production) that many libraries — including Express — check to decide things like whether to cache views or show verbose error stacks. Good practice is to validate required env vars at startup (failing fast with a clear error if something's missing) rather than discovering a missing config value halfway through a request, and to never commit .env files or real secrets to git — only a .env.example template. For more complex setups, a config module that centralizes and type-checks all the environment variables in one place (rather than scattering process.env.X reads throughout the codebase) makes it much easier to see what configuration the app actually depends on.

12. What's the difference between child_process and worker_threads, and when would you use each?

child_process (via spawn, exec, or fork) creates an entirely separate OS process with its own memory space and its own V8 instance, communicating with the parent via IPC (typically serialized messages, or stdin/stdout pipes) — it's the right tool for running external programs (like ffmpeg or a shell command) or for isolating a crash-prone task so it can't take down your main process. worker_threads, added later, creates threads within the same process that share the same V8 instance but each get their own isolated JS heap and event loop, communicating via message passing (and optionally SharedArrayBuffer for zero-copy shared memory) — they're lighter-weight to spin up than a full process and are the better fit for CPU-bound work (image processing, heavy computation) that would otherwise block Node's single main thread. In short: reach for child_process when you need process isolation or need to run something outside your Node code, and worker_threads when you want to parallelize CPU-intensive JavaScript without paying the overhead of a full new process. Neither is needed for I/O-bound work — that's exactly what the event loop already handles efficiently on a single thread.

13. What are some key principles you follow when designing a REST API in Express?

Resources should be modeled as nouns in the URL path (e.g., /users/:id/orders) with HTTP verbs conveying the action — GET to read, POST to create, PUT/PATCH to update, DELETE to remove — rather than encoding verbs into the path itself. Status codes should be meaningful and consistent (201 for successful creation, 204 for a successful delete with no body, 400 for client validation errors, 401/403 for auth failures, 404 for missing resources, 500 for unhandled server errors) so clients can branch on them programmatically instead of parsing error messages. Cross-cutting concerns — auth, input validation, rate limiting, logging, error handling — belong in middleware rather than duplicated in each route handler, and routes themselves are best organized with express.Router() into separate files per resource rather than one giant app.js. Versioning the API (e.g., a /v1/ prefix) from the start avoids painful breaking changes later, and consistent response shapes (e.g., always wrapping data in { data, error } or similar) make the API predictable for whoever's consuming it.

14. Walk through how JWT-based authentication typically works in a Node/Express app.

On login, the server verifies the user's credentials against the database, then signs a JWT (using something like jsonwebtoken) containing a payload — typically the user ID and maybe roles, but never sensitive data since the payload is only base64-encoded, not encrypted — and sends it back to the client. The client stores the token (in memory, localStorage, or an httpOnly cookie, each with different XSS/CSRF tradeoffs) and attaches it to subsequent requests, usually as an Authorization: Bearer <token> header. On the server, an authentication middleware verifies the token's signature and expiry using the secret (or public key, for asymmetric signing) before the request reaches the route handler, attaching the decoded payload (e.g., req.user) so downstream handlers know who's making the request. Because JWTs are stateless and can't be individually revoked before they expire, a common pattern is to keep access tokens short-lived (minutes) and pair them with a longer-lived refresh token (stored server-side or as an httpOnly cookie) that can be revoked, used to mint new access tokens without forcing the user to log in again.

15. Why do you need connection pooling when talking to a database from Node, and how does it work?

Opening a new database connection is expensive — it involves a TCP handshake, authentication, and setup on the DB server side — so making every incoming request open and close its own connection would tank throughput and quickly exhaust the database's max-connections limit under any real load. A connection pool (built into drivers like pg, or configurable in an ORM like Prisma/Sequelize/TypeORM) pre-establishes a set number of connections at startup and hands them out to queries as needed, returning each connection to the pool when the query finishes rather than closing it, so subsequent queries reuse an already-open connection. Pool size needs to be tuned relative to both the app's concurrency and the database's own connection limit — too small and requests queue waiting for a free connection, too large and you risk overwhelming the database, especially if you're running multiple Node instances (e.g., behind a cluster or in multiple containers) all pooling against the same DB. This is also why serverless environments (where each invocation can be a fresh process) are notoriously tricky for traditional connection pooling, often requiring an external pooler like PgBouncer sitting between the app and the database.

16. How would you go about diagnosing a memory leak in a running Node.js process?

Start by confirming there actually is a leak — watch process.memoryUsage().heapUsed (or a tool like PM2's monitoring) over time under steady load; a real leak shows heap usage climbing and never coming back down after garbage collection, rather than just normal sawtooth GC behavior. Once confirmed, run the process with --inspect and connect Chrome DevTools (or use a tool like heapdump/clinic.js) to take heap snapshots at two points in time, then diff them to see which object types are accumulating and retained-size growing — that usually points straight at the leaking reference. Common culprits in Node specifically are event listeners that are added but never removed (each accumulating closures over request-scoped data), module-level caches or arrays that grow unbounded, closures unintentionally holding references to large objects, and timers (setInterval) that are never cleared. Once you've found the retaining object, trace its retainer chain in the snapshot back to see what's holding the reference alive longer than intended, and fix it by explicitly removing listeners/clearing timers or scoping the reference more tightly.

17. What is the EventEmitter pattern in Node, and where is it used internally?

EventEmitter is a core Node class implementing the publish/subscribe pattern — you call .on(eventName, handler) to subscribe a callback to a named event, and .emit(eventName, ...args) to synchronously invoke all subscribed handlers with those arguments, which decouples the code that triggers something from the code that reacts to it. It's used extensively throughout Node's own core APIs: HTTP servers emit 'request' and 'connection' events, streams emit 'data', 'end', and 'error', and process itself is an EventEmitter that emits things like 'exit' and 'uncaughtException' — so understanding EventEmitter is really understanding a big chunk of Node's core API surface, not just a userland pattern. A few gotchas worth knowing: emitting an 'error' event with no listener attached throws and crashes the process by default, EventEmitter warns (by default) if more than 10 listeners are added for a single event on one emitter (usually a sign of a leak, adjustable via setMaxListeners), and .once() is available when you only want a handler to fire a single time. Custom classes commonly extend EventEmitter to build their own pub/sub-style APIs rather than reimplementing the pattern from scratch.

18. What is the require cache in Node, and why does it matter?

The first time a module is require()'d, Node executes its code and caches the resulting module.exports object (keyed by resolved file path) in require.cache; every subsequent require() of that same file — from anywhere in the app — returns the cached export object instead of re-running the module's code. This matters for a few reasons: it's why module-level state (like a singleton database connection or an in-memory config object) initialized in one file behaves as a true singleton across the whole app, since every importer gets the same object reference. It also explains a classic gotcha with circular dependencies — if module A requires B and B requires A before A has finished executing, B gets whatever partial exports A had defined so far, which can be an empty object. In testing, you sometimes need to manually delete a module from require.cache (or use jest.resetModules()) to force a fresh, un-cached re-evaluation of a module between test cases, since the caching that's usually convenient in production can leak state between otherwise-isolated tests.

Related topics
Spring Boot