The 62 Node.js questions interviewers actually ask, with direct answers, runnable code, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced backend rounds.
62 questions with answersKey Takeaways
Node.js is a JavaScript runtime built on Chrome's V8 engine that runs JavaScript on the server, released by Ryan Dahl in 2009 and now stewarded by the OpenJS Foundation. It pairs V8 with libuv, a C library that provides the event loop and a thread pool, so a single JavaScript thread can juggle thousands of concurrent connections without a thread per request. That design makes Node.js a strong fit for APIs, real-time services, and streaming, and a weaker fit for CPU-bound number crunching. In interviews, Node.js questions probe the runtime model (the event loop, non-blocking I/O, the thread pool) and backend judgment (error handling, streams, security, production behavior), not memorized syntax. This page collects the 62 questions that come up most, each with a direct answer and runnable code. If you're still building fundamentals, the OpenJS Foundation's Introduction to Node.js is the canonical starting point; increasingly the first backend round is a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: Node.js Crash Course
Video: Node.js Crash Course (Traversy Media, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your Node.js certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
Node.js is a runtime that runs JavaScript outside the browser, built on Chrome's V8 engine and paired with libuv for I/O. It lets you write servers, CLIs, and build tools in JavaScript, so one language covers the whole stack.
You reach for it when the work is I/O-bound: APIs, real-time apps, streaming, and services that hold many concurrent connections. Its non-blocking, event-driven model handles thousands of idle connections cheaply, which is exactly what those workloads need.
Key point: A one-line definition plus one concrete use case (an API or a real-time app) beats a feature list. Interviewers open with this to hear how you frame an answer.
Watch a deeper explanation
Video: Node.js Ultimate Beginner's Guide in 7 Easy Steps (Fireship, YouTube)
Your JavaScript runs on a single main thread. Node.js isn't fully single-threaded though: libuv maintains a background thread pool, and V8 uses threads for garbage collection and compilation. What matters is that your code, your callbacks, all run on one thread.
One thread handles many requests because most request work is waiting on I/O (database, network, disk), and Node.js doesn't block on that wait. It registers a callback, moves on to other requests, and the event loop resumes each callback when its I/O finishes. The thread is only busy during the tiny slices of actual computation.
Key point: The trap is saying Node.js is 'single-threaded' flatly. Nuance (your JS is single-threaded, libuv is not) is exactly what this screens for.
The event loop is the mechanism that lets a single thread do non-blocking I/O: it repeatedly checks queues of completed operations and runs their callbacks. libuv runs it, cycling through ordered phases.
Each full turn runs phases in order: timers (setTimeout, setInterval), pending callbacks, poll (retrieve new I/O and run I/O callbacks), check (setImmediate), and close callbacks. Between every phase, and after each callback, Node.js drains the microtask queues: process.nextTick first, then Promise callbacks.
One turn of the Node.js event loop
After each callback and between phases, microtasks drain: process.nextTick first, then resolved Promises.
Key point: Knowing the phase order and that microtasks drain between them is the senior-sounding detail. Most candidates stop at 'it handles callbacks'.
Blocking code stops the single thread until the operation finishes: readFileSync reads the whole file before the next line runs, and while it does, no other request gets served. Non-blocking code hands the operation to libuv, registers a callback, and returns immediately, so the thread stays free.
This is the whole point of Node.js. One synchronous, slow operation on the main thread stalls every other request. Prefer the async APIs, and keep CPU-heavy work off the main thread.
const fs = require("fs");
// Blocking: nothing else runs until the read finishes
const data = fs.readFileSync("big.log", "utf8");
console.log(data.length);
// Non-blocking: the thread is free while libuv reads
fs.readFile("big.log", "utf8", (err, data) => {
if (err) throw err;
console.log(data.length);
});Key point: Have a concrete example ready (readFileSync vs readFile). Naming the sync API you'd avoid shows you've felt the pain.
Watch a deeper explanation
Video: Async JS Crash Course - Callbacks, Promises, Async Await (Traversy Media, YouTube)
CommonJS is Node.js's original module system: require() to import, module.exports to export, synchronous and dynamic, the default for .js files. ES modules (ESM) are the JavaScript standard: import and export, statically analyzed, asynchronous, with top-level await support.
You opt into ESM with a .mjs extension or "type": "module" in package.json. The practical friction: you can't require() an ESM-only package from CommonJS directly, which is behind a lot of 'ERR_REQUIRE_ESM' confusion.
// CommonJS
const express = require("express");
module.exports = { start };
// ES modules
import express from "express";
export function start() {}Key point: Say why the choice matters (top-level await, static analysis) rather than just listing syntax. The follow-up is usually 'why can't CommonJS require an ESM-only package?'.
npm is Node's package manager. package.json declares your project's metadata, scripts, and dependency ranges (like "express": "^4.18.0"). package-lock.json records the exact resolved version of every package in the whole tree, direct and transitive.
The lock file is what makes installs reproducible: two machines running npm install off the same lock get byte-for-byte the same node_modules. Commit it. Use npm ci in CI, which installs strictly from the lock and fails if it's out of sync.
Key point: npm ci and 'commit the lock file' matters.
They're three generations of handling async results. Callbacks came first: pass a function that runs when the work finishes, by convention (err, result). Promises wrap that into an object you can chain with .then and .catch, which flattens nesting. async/await is syntax over Promises: await pauses the function until the Promise settles, so async code reads top to bottom.
Modern Node.js code uses async/await with try/catch. Callbacks still appear in older APIs and event emitters, and util.promisify converts a callback API to a Promise one.
const fs = require("fs/promises");
async function loadConfig(path) {
try {
const text = await fs.readFile(path, "utf8");
return JSON.parse(text);
} catch (err) {
console.error("config failed:", err.message);
return {};
}
}Key point: the key point is that async/await is Promises underneath, not a separate thing. Saying 'await unwraps a Promise' shows you understand the mechanism.
Callback hell is deeply nested callbacks, each depending on the previous result, drifting rightward into a pyramid that's hard to read and error-prone because every level needs its own error check.
Fix it with Promises and async/await: chained .then calls flatten the pyramid, and await makes sequential async steps read like normal code. Named functions and Promise.all for independent work also help.
// pyramid of doom
getUser(id, (e, user) => {
getOrders(user, (e, orders) => {
getTotals(orders, (e, totals) => { /* ... */ });
});
});
// flattened with async/await
const user = await getUser(id);
const orders = await getOrders(user);
const totals = await getTotals(orders);Key point: Don't just say 'use async/await'. Point out that each callback level also needed its own error check, which is the real cost async/await removes.
npm installs and manages packages. npx executes a package's binary, downloading it temporarily if it isn't installed, so you can run a one-off tool (npx create-react-app) without a global install that lingers.
The everyday value is running the locally installed version of a CLI (npx eslint) rather than a possibly-different global one, which keeps behavior consistent with what your project depends on.
Key point: A crisp one-liner works here: npm installs, npx runs. Then add the local-version detail if they push for more.
Node.js caches modules after the first require. The first time, it runs the module's top-level code once and stores the resulting exports; every later require of the same resolved path returns that cached exports object without re-running the file.
This is why module-level state (a counter, a database connection) is shared across every file that imports it, effectively a singleton. It's also why a change to a module's top-level side effects only runs once per process, a fact that surprises people debugging import-time behavior.
// counter.js
let count = 0;
module.exports = () => ++count;
// a.js and b.js both require("./counter")
// they share ONE counter because the module is cached
const bump = require("./counter");
bump(); // 1
bump(); // 2Key point: The module cache is the answer to 'how do you make a singleton in Node?'. it in practice matters.
In CommonJS, __dirname is the absolute path of the current module's directory and __filename its full path, both handy for building paths that don't depend on the working directory. global is Node's top-level object, the server-side equivalent of the browser's window.
In ES modules, __dirname and __filename don't exist; you derive them from import.meta.url. Reaching for path.join(__dirname, ...) instead of string concatenation is the detail interviewers like to see.
const path = require("path");
const configPath = path.join(__dirname, "config", "app.json");
// ES modules equivalent
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);process is a global that represents the running Node.js process. You use it to read command-line arguments (process.argv), environment variables (process.env), the current working directory (process.cwd()), and to control exit (process.exit()).
It's also an EventEmitter for lifecycle signals: 'exit', 'SIGTERM', 'uncaughtException'. Production services listen on these for graceful shutdown, which comes up in advanced rounds.
const port = process.env.PORT || 3000;
const [, , command] = process.argv;
process.on("SIGTERM", () => {
console.log("shutting down");
process.exit(0);
});Key point: process is an EventEmitter (for SIGTERM).
Read deployment-specific values from process.env, set by the platform. In development, a .env file loaded by the dotenv package (or Node's built-in --env-file flag) keeps local values out of the code, and you never commit that file.
The production discipline is: validate the variables you need once at startup, fail loudly if a required one is missing, and keep secrets in the platform's secret manager rather than the repo.
require("dotenv").config();
const { DATABASE_URL, JWT_SECRET } = process.env;
if (!DATABASE_URL || !JWT_SECRET) {
throw new Error("missing required env vars");
}The built-in http module's createServer takes a handler that receives the request and response objects for every request. You set the status and headers on the response and end it with the body.
Frameworks like Express wrap this to add routing, middleware, and conveniences, but knowing the raw version shows you understand what Express is doing underneath.
const http = require("http");
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, path: req.url }));
});
server.listen(3000, () => console.log("listening on 3000"));Key point: Being able to write the raw http server is a quiet flex: it shows Express isn't magic to you. Interviewers sometimes ask exactly this to see if you know the layer beneath the framework.
Express is a minimal web framework for Node.js. It adds routing, a middleware pipeline, and request/response helpers on top of the http module, without dictating structure, which is why so much of the ecosystem is built around it.
Its staying power is that middleware model: request handling is a chain of small functions, each able to inspect, modify, respond, or pass control on. That pattern is easy to reason about and extend.
Middleware is a function with access to the request, the response, and a next function. It runs in the order registered, and it either responds, or calls next() to pass control to the next middleware in the chain. Logging, parsing bodies, authentication, and validation are all middleware.
The mental model is a pipeline: a request flows through each middleware until one sends a response. Forgetting to call next() (and not responding) is the classic bug that hangs a request.
const express = require("express");
const app = express();
app.use(express.json()); // built-in: parse JSON bodies
app.use((req, res, next) => { // custom logger
console.log(`${req.method} ${req.url}`);
next(); // pass control on
});Key point: The follow-up is 'what happens if you forget next()?'. Answer: the request hangs. Volunteering it shows real Express experience.
Watch a deeper explanation
Video: Learn Express Middleware In 14 Minutes (Web Dev Simplified, YouTube)
You map an HTTP method and a path to a handler: app.get("/users/:id", handler). Route parameters (:id) land in req.params, query strings in req.query, and the parsed body in req.body. Handlers send a response with res.json, res.send, or res.status.
For larger apps, express.Router() groups related routes into a module you mount under a prefix, which keeps the main file small and the API organized.
const router = express.Router();
router.get("/:id", (req, res) => {
res.json({ id: req.params.id });
});
app.use("/users", router); // GET /users/42 -> { id: "42" }Watch a deeper explanation
Video: Learn Express JS In 35 Minutes (Web Dev Simplified, YouTube)
req represents the incoming HTTP request: req.params (route params), req.query (query string), req.body (parsed body), req.headers, and req.method. res is the outgoing response: res.status() sets the code, res.json() and res.send() write the body and end the response, res.set() sets headers.
The rule that trips people up: a response can only be sent once. Calling res.json() twice, or sending after next(), throws 'headers already sent'.
fs.readFileSync blocks the thread until the file is read; fs.readFile hands the read to libuv and calls back when done; fs.promises.readFile does the same but returns a Promise you can await. Same operation, three concurrency behaviors.
In a server, use the async versions so one file read doesn't stall every other request. The sync versions are fine at startup (loading config once) where nothing else is happening yet.
A Buffer is a fixed-length chunk of raw binary data outside V8's heap. Node.js uses buffers for anything that isn't text: file contents, network packets, image bytes, crypto. Streams deliver their chunks as buffers by default.
You convert between buffers and strings with an explicit encoding: buf.toString("utf8") or Buffer.from(str, "utf8"). Getting the encoding wrong is a common source of mojibake in file and network code.
const buf = Buffer.from("héllo", "utf8");
console.log(buf.length); // 6 bytes, not 5 chars
console.log(buf.toString("hex"));
console.log(buf.toString("utf8")); // "héllo"Key point: The bytes-vs-characters distinction (buf.length counts bytes, not characters) is the detail interviewers probe. The héllo example makes it land.
JSON.parse turns a JSON string into JavaScript objects; JSON.stringify does the reverse. In an API, express.json() middleware parses request bodies into req.body for you, and res.json() stringifies your response.
The gotcha worth naming: JSON.parse throws on invalid input, so parsing untrusted data belongs in a try/catch. And JSON has no date type, so dates round-trip as strings unless you handle them.
Node.js's classic async APIs pass the error as the first argument to the callback and the result second: callback(err, data). You check err before touching data, every time. If err is null, the operation succeeded.
This convention predates Promises and still shows up in older libraries and stream events. Modern code usually promisifies these, but the question expects you to recognize and handle the pattern.
fs.readFile("data.txt", "utf8", (err, data) => {
if (err) {
console.error("read failed:", err.message);
return; // always return after handling err
}
console.log(data);
});The scripts section of package.json names shell commands you run with npm run <name>: start, test, build, lint. It's the standard entry point so teammates don't memorize long commands.
nodemon is a development tool that watches your files and restarts the server on every change, so you don't stop and start Node by hand. It's a dev dependency, not something you run in production.
The everyday set: 200 for a successful GET, 201 for a created resource, 204 for a successful request with no body, 400 for a malformed request, 401 for missing or bad authentication, 403 for authenticated-but-forbidden, 404 for not found, 409 for a conflict, 422 for validation failure, and 500 for a server error.
The distinction interviewers probe most is 401 versus 403 (who you are versus what you're allowed to do) and 400 versus 500 (the client's fault versus the server's). Getting those right signals API maturity.
Key point: Nail 401 vs 403 and 400 vs 500 out loud. Those two distinctions are the whole reason this question gets asked.
For candidates with working experience: runtime mechanics, streams, and the backend judgment that separates users from understanders.
libuv is the C library that gives Node.js its event loop and cross-platform async I/O. For operations the OS exposes asynchronously (network sockets), libuv uses the kernel directly. For operations without a good async primitive (file system, DNS lookups, some crypto), it offloads to a thread pool so the main thread never blocks.
The pool defaults to 4 threads, tunable with UV_THREADPOOL_SIZE. It matters because heavy fs or crypto work can saturate those 4 threads and queue behind each other, a subtle bottleneck that looks like the event loop is slow when it's actually the pool.
| Operation type | Handled by | Why |
|---|---|---|
| Network I/O (sockets) | OS async primitives | Kernel provides non-blocking sockets directly |
| File system, DNS | libuv thread pool | No portable async OS primitive, so offload to threads |
| crypto.pbkdf2, zlib | libuv thread pool | CPU-heavy but off the main thread |
| Your JavaScript | Main thread (V8) | Single-threaded execution of your code |
Key point: Knowing that fs and DNS use the pool (not the kernel) and that the default is 4 is the detail that separates memorized answers from understanding.
process.nextTick queues a callback to run right after the current operation, before the event loop continues to any phase, and it drains fully before I/O. Promise callbacks (microtasks) run in the same slot, just after nextTick. setImmediate schedules a callback for the check phase of the next loop iteration, after I/O.
The priority order within one turn: current code, then all nextTick callbacks, then all Promise microtasks, then the loop proceeds. Overusing process.nextTick can starve I/O because it keeps jumping the queue.
setImmediate(() => console.log("immediate"));
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
console.log("sync");
// order: sync, nextTick, promise, immediateKey point: Being able to predict the output order of this exact snippet is a common live check. Practice it until it's automatic.
Streams process data in chunks over time instead of loading it all into memory. The four types: Readable (a source, like fs.createReadStream or an HTTP request), Writable (a destination, like fs.createWriteStream or an HTTP response), Duplex (both, like a TCP socket), and Transform (a Duplex that modifies data passing through, like zlib compression).
The payoff is memory: streaming a 2 GB file uses kilobytes at a time, while readFile loads all 2 GB. Streams are also composable, you pipe a readable through transforms into a writable.
const fs = require("fs");
const zlib = require("zlib");
fs.createReadStream("input.txt") // Readable
.pipe(zlib.createGzip()) // Transform
.pipe(fs.createWriteStream("input.txt.gz")); // WritableKey point: Lead with the memory payoff (kilobytes at a time, not the whole file). Naming the four types is table stakes; explaining why you'd stream is the signal.
Backpressure is what happens when a readable produces data faster than a writable can consume it. Without control, the unconsumed chunks pile up in memory until the process runs out. The fix is to pause the readable when the writable's internal buffer is full, and resume when it drains.
pipe() handles this automatically: it watches the writable's return value and pauses the source. The problem is pipe() doesn't propagate errors well, so a failure can leak the stream. pipeline() (from stream/promises) is the modern choice: same backpressure handling plus proper error propagation and cleanup.
const { pipeline } = require("stream/promises");
const fs = require("fs");
const zlib = require("zlib");
await pipeline(
fs.createReadStream("big.log"),
zlib.createGzip(),
fs.createWriteStream("big.log.gz")
); // errors reject the promise; streams are cleaned upKey point: Volunteering that you prefer pipeline over pipe 'because pipe leaks streams on error' is a strong production signal.
EventEmitter is Node's implementation of the observer pattern: an object emits named events with .emit(name, ...args), and listeners registered with .on(name, handler) run synchronously in registration order when that event fires. Streams, the HTTP server, and the process object are all emitters.
Two things to know: emit is synchronous, so a slow listener blocks the emitter; and an 'error' event with no listener throws and crashes the process, so you always attach an 'error' handler.
const EventEmitter = require("events");
class Job extends EventEmitter {}
const job = new Job();
job.on("done", (result) => console.log("finished:", result));
job.on("error", (err) => console.error(err.message)); // always
job.emit("done", 42);Key point: The 'error' event with no listener crashing the process is the follow-up interviewers wait for. Mention it before they ask.
Each style has its own mechanism. Callbacks: check the error-first argument. Promises: .catch() at the end of the chain. async/await: try/catch around the await, or a .catch on the returned Promise. The mistake is mixing them, like putting a synchronous try/catch around a callback API, where it catches nothing.
In Express, an error thrown in an async route won't reach your error middleware unless you catch it and call next(err), or wrap handlers so rejections forward automatically. That gap is a frequent source of silently hanging requests.
// wrapper so async route errors reach error middleware
const wrap = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get("/users/:id", wrap(async (req, res) => {
const user = await db.find(req.params.id); // rejection -> next(err)
res.json(user);
}));Key point: The async-route-to-error-middleware gap is a favorite intermediate question. Knowing the wrap pattern (or that Express 5 handles it) sets you apart.
An uncaught synchronous exception fires process 'uncaughtException'; an unhandled rejected Promise fires 'unhandledRejection'. Since Node 15, an unhandled rejection crashes the process by default, matching uncaught exceptions.
The correct posture: don't use these handlers to keep running as if nothing happened, because the process is now in an unknown state. Log the error, alert, and let the process exit so a supervisor restarts it clean. Recover state through your infrastructure, not by swallowing crashes.
process.on("unhandledRejection", (reason) => {
console.error("unhandled rejection:", reason);
process.exit(1); // log, then exit; let the supervisor restart
});cluster forks the process into multiple workers that share the same server port, so a machine with 8 cores can run 8 Node processes instead of leaving 7 idle. The primary process distributes incoming connections across workers.
Use it to scale a stateless HTTP server across CPU cores on one box. Each worker is a separate process with its own memory, so shared state has to live elsewhere (Redis, a database). In containerized setups, running one process per container and scaling containers often replaces cluster.
Key point: Add the 'workers don't share memory' point and the container alternative. It shows you know cluster's limits, not just that it exists.
worker_threads run JavaScript on real OS threads inside one process, sharing memory through SharedArrayBuffer and passing messages otherwise. They exist for CPU-bound work: image processing, parsing, cryptography, computation that would otherwise block the event loop.
The difference from cluster is the unit and the purpose. cluster forks whole processes to scale I/O across cores; worker_threads add threads within a process to offload CPU work off the main thread. Reach for cluster to serve more requests, worker_threads to keep one request's heavy computation from freezing the others.
| cluster | worker_threads | |
|---|---|---|
| Unit | Separate processes | Threads in one process |
| Memory | Isolated per process | Can share via SharedArrayBuffer |
| Best for | Scaling I/O across CPU cores | Offloading CPU-bound work |
| Startup cost | Higher (full process) | Lower (thread) |
Key point: The one-line answer the question needs: cluster scales I/O across cores, worker_threads offload CPU work. Lead with that, then add detail.
The child_process module spawns other programs. spawn streams output and suits long-running or large-output commands; exec buffers the whole output and suits short commands (but risks buffer overflow on big output); fork is a special case that spawns a Node script with a message channel to the parent.
The security note that matters: exec runs a shell, so interpolating user input into the command string is a command-injection hole. Prefer spawn with an argument array, which doesn't invoke a shell.
const { spawn } = require("child_process");
// safe: args as an array, no shell interpolation
const ls = spawn("ls", ["-la", userDir]);
ls.stdout.on("data", (chunk) => process.stdout.write(chunk));
ls.on("close", (code) => console.log("exited", code));Key point: The exec-runs-a-shell command-injection point turns a routine answer into a security-aware one. Always contrast spawn (array args, no shell) with exec.
Session auth stores state on the server: on login you create a session, store it (memory, Redis, a database), and hand the client a cookie holding the session id. Every request looks up that session. JWT auth is stateless: the server signs a token containing the user's claims, the client sends it on each request, and the server verifies the signature without a lookup.
The trade-off is state versus revocation. Sessions are trivially revocable (delete the record) but need shared storage to scale. JWTs scale without shared state but can't be revoked before expiry unless you add a denylist, which reintroduces the state you were avoiding. Keep JWT lifetimes short and pair with refresh tokens.
| Sessions | JWT | |
|---|---|---|
| State | Server-side store | Stateless (in the token) |
| Revocation | Immediate (delete session) | Hard until expiry (needs denylist) |
| Scaling | Needs shared store (Redis) | No shared store needed |
| Best for | Traditional web apps | APIs, microservices, mobile |
Key point: The revocation trade-off is the follow-up interviewers chase. Say JWTs are hard to revoke and how you'd handle it (short expiry, refresh tokens, denylist).
Watch a deeper explanation
Video: JWT Authentication Tutorial - Node.js (Web Dev Simplified, YouTube)
Validate and sanitize every input (a schema validator like Zod or Joi), use parameterized queries or an ORM to prevent SQL injection, hash passwords with bcrypt or argon2 (never plain or fast hashes), and set security headers with helmet. Add rate limiting, keep secrets in environment variables, and enable CORS deliberately rather than wide open.
The one that catches teams is dependencies: run npm audit, keep packages current, and remember a vulnerability three levels deep in your tree is still your vulnerability. Also avoid leaking stack traces to clients in production.
Key point: The a specific tool per risk (helmet, bcrypt, a validator, npm audit). Concrete tooling indicates real experience, not a checklist recital.
CORS (Cross-Origin Resource Sharing) is a browser security mechanism: by default a page can't call an API on a different origin unless that API opts in with the right response headers. It's enforced by the browser, not the server, which is why a request works from curl but fails in the browser.
In Express you configure it with the cors middleware: set the allowed origins explicitly, the allowed methods and headers, and whether credentials are allowed. The mistake is reflexively allowing all origins (*) with credentials, which the spec forbids and which weakens your security.
const cors = require("cors");
app.use(cors({
origin: ["https://app.example.com"], // explicit, not "*"
methods: ["GET", "POST"],
credentials: true,
}));Model resources as nouns and use HTTP methods as the verbs: GET /users, POST /users, GET /users/:id, PUT/PATCH /users/:id, DELETE /users/:id. Return the right status codes, keep responses consistent, and use plural nouns and nesting for relationships (GET /users/:id/orders).
Beyond the basics: version the API (/v1), paginate list endpoints, make writes idempotent where you can (PUT, DELETE), and return errors in a consistent shape. GET must be side-effect free, which is what lets clients and proxies cache it safely.
Key point: The idempotency and 'GET has no side effects' points separate The production-ready answer from a CRUD recital. Work at least one in.
Middleware runs in the order you register it, top to bottom, so order is logic. Body parsing must come before routes that read req.body. Authentication must come before the routes it protects. A 404 handler goes after all routes, and error-handling middleware goes last of all.
Get the order wrong and you get silent bugs: routes that see an empty body, auth that runs too late to protect anything, or an error handler that never fires because it was registered before the code that throws.
Use a connection pool, not a new connection per request. A pool keeps a set of open connections and hands them out, which avoids the cost of connecting on every query and caps how many connections you open against the database. Libraries like pg, mysql2, and ORMs (Prisma, Sequelize) create pools for you.
Two production details: always release connections back to the pool (finally blocks or the library's managed query), and set sensible pool limits so a traffic spike doesn't exhaust the database's connection cap.
const { Pool } = require("pg");
const pool = new Pool({ max: 10 });
async function getUser(id) {
const { rows } = await pool.query(
"SELECT * FROM users WHERE id = $1", [id] // parameterized
);
return rows[0];
}Every outbound call needs a timeout, otherwise one slow dependency can pile up requests until the process runs out of resources. The modern tool is AbortController: create one, pass its signal to fetch (or a supporting library), and abort it on a timer. The request rejects with an abort error you catch and translate to a 503 or a retry.
The senior point is that timeouts belong on every network boundary, database, cache, and third-party API, tuned to how long the caller can actually wait, not left at the library default of 'forever'. Pair them with retries and a circuit breaker so a slow dependency degrades gracefully instead of taking the service down.
async function fetchWithTimeout(url, ms = 2000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timer); // always clear the timer
}
}Key point: Saying 'every network call gets a timeout' is a strong production signal. Interviewers have seen outages caused by exactly the missing timeout you're naming.
Awaiting in a loop runs operations one after another, which is right when each depends on the last. For independent operations, kick them all off and await together with Promise.all, which runs them concurrently and resolves when all finish, cutting total time to the slowest one.
Know the variants: Promise.all rejects on the first failure; Promise.allSettled waits for all and reports each outcome; Promise.race resolves on the first to settle. Choosing the right one is a common intermediate check.
// sequential: ~3x the time
const a = await fetchA();
const b = await fetchB();
const c = await fetchC();
// parallel: ~1x the slowest
const [a2, b2, c2] = await Promise.all([fetchA(), fetchB(), fetchC()]);Unit test pure logic with Jest, Vitest, or the built-in node:test runner. Integration test API routes by sending real requests through the app with supertest and asserting status and body. Mock external boundaries (network, clock, third-party APIs) so tests are fast and deterministic.
A strong answer separates the levels: many fast unit tests, fewer integration tests over the real app and a test database, and a thin layer of end-to-end tests. Testing behavior at the route boundary catches more real bugs than over-mocking internals.
const request = require("supertest");
const app = require("../app");
test("GET /health returns 200", async () => {
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});the inspector: node --inspect (or --inspect-brk to break on the first line) opens a debugging port you attach Chrome DevTools or VS Code to, for breakpoints, stepping, and watching variables comes first. That beats scattering console.log for anything non-trivial.
For a live process you can't restart, send SIGUSR1 to enable the inspector, or use a sampling profiler like 0x or clinic to see where time goes. Structured logging (pino, winston) with request ids makes production issues traceable after the fact.
Graceful shutdown means stopping cleanly when the platform sends SIGTERM: stop accepting new connections, finish in-flight requests, close database pools and other resources, then exit. Without it, you drop live requests and leak connections on every deploy.
You listen for SIGTERM, call server.close() to stop accepting new requests while letting current ones finish, close your resources, and exit. Add a timeout so a stuck request can't block shutdown forever.
process.on("SIGTERM", async () => {
server.close(async () => { // stop new, finish in-flight
await pool.end(); // close DB pool
process.exit(0);
});
setTimeout(() => process.exit(1), 10000).unref(); // hard cap
});advanced rounds probe internals, design judgment, and production scars. Expect every answer here to draw a follow-up.
V8 parses your source to an abstract syntax tree, generates bytecode with the Ignition interpreter, and starts running immediately. As functions run hot, the TurboFan optimizing compiler recompiles them to fast machine code based on the types it has seen, and deoptimizes back to bytecode if those assumptions break.
The practical consequence: keeping object shapes and argument types stable helps V8 keep optimized code, while polymorphic functions that see many shapes stay slow. It's rarely worth micro-optimizing for, but understanding it explains why some hot paths surprise you.
Confirm the leak first: watch RSS and heap used climb across a steady workload without settling. Then take heap snapshots over time with --inspect and Chrome DevTools (or the heapdump module) and diff them to find which object type keeps growing and what's retaining it.
The usual culprits: unbounded caches and module-level arrays or maps that only grow, event listeners added but never removed, closures capturing large objects, and timers that keep references alive. Fixes are a bounded cache (an LRU with a max size), removing listeners, and breaking retained references, then re-measure to confirm.
Heap over time: leaking vs healthy service
Heap used across a steady workload. A leak climbs without settling; a healthy service sawtooths as GC reclaims memory.
Key point: Lead with 'measure before fixing' and The snapshot-diff workflow. Jumping straight to a suspected cause without measuring is the answer that loses points.
Event loop lag is the delay between when a callback should run and when it actually does, caused by something hogging the single thread: a synchronous CPU-heavy operation, a giant JSON.parse, a tight loop, or a blocking library call. While the thread is busy, every other request waits.
Detect it by measuring the loop's delay (perf_hooks monitorEventLoopDelay, or a simple timer that records how late it fires) and alerting on the p99. Prevent it by moving CPU work to worker_threads, chunking large synchronous operations, streaming instead of buffering, and never using the sync fs or crypto APIs on the request path.
const { monitorEventLoopDelay } = require("perf_hooks");
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => {
console.log("loop p99 (ms):", (h.percentile(99) / 1e6).toFixed(1));
}, 5000);Key point: The a concrete cause (a big synchronous JSON.parse) and a concrete fix (worker_threads). Abstract answers about 'blocking' lose to specific ones here.
Subclass Transform (or pass a transform function) and implement _transform(chunk, encoding, callback): process the chunk, push results, and call the callback when done. Optionally implement _flush for trailing data. You build one to process data incrementally as it flows: parse NDJSON line by line, redact fields in a log pipeline, or reshape records between a source and a sink without buffering the whole dataset.
The senior detail is respecting backpressure and object mode: honor the highWaterMark, set objectMode when you're passing objects rather than bytes, and propagate errors through the callback so pipeline can clean up.
const { Transform } = require("stream");
const upper = new Transform({
transform(chunk, _enc, cb) {
cb(null, chunk.toString().toUpperCase());
},
});
source.pipe(upper).pipe(destination);Split by call pattern. Synchronous request/response (a service needs an answer now) goes over HTTP or gRPC, with timeouts, retries with backoff, and a circuit breaker so a slow dependency doesn't cascade. Asynchronous, event-driven work (a service reacts to something that happened) goes over a message broker (Kafka, RabbitMQ, SQS), which decouples services and absorbs bursts.
The judgment interviewers probe: don't make everything synchronous (you build a distributed monolith that fails together), and don't make everything async (you lose the simplicity where a direct call is fine). Also plan for idempotency, since at-least-once delivery means consumers must handle duplicates.
Key point: Naming circuit breakers, retries with backoff, and idempotent consumers signals you've run services in production, not just read about the pattern.
Layer them. In-process caching (a Map or an LRU) is fastest but per-instance and lost on restart, good for small hot data. A shared cache like Redis is the workhorse: it survives restarts, is shared across instances, and supports TTLs and eviction. HTTP caching (Cache-Control, ETags) pushes work to clients and CDNs for cacheable GETs.
The hard parts are invalidation and stampedes. Set TTLs so stale data self-heals, invalidate on writes for correctness-critical data, and guard against a cache stampede (many requests recomputing the same expired key at once) with a lock or a single-flight pattern.
Keep instances stateless: session and cache state live in Redis or a database, not in process memory, so any instance can serve any request. Then run many instances behind a load balancer and scale the count with load. Inside one instance, cluster or one-process-per-container uses all CPU cores.
The constraints that decide the ceiling are usually downstream: the database connection cap (which is why pooling and connection limits matter), and shared resources that don't scale as easily as the stateless tier. The senior framing is that scaling Node is mostly about pushing state out and finding the real bottleneck, which is rarely the Node process itself.
Expose two endpoints. Liveness answers 'is the process alive and not deadlocked?' and stays cheap so the orchestrator doesn't kill a healthy process under load. Readiness answers 'can this instance serve traffic right now?' and checks real dependencies (database reachable, migrations done, caches warm), returning non-200 when the instance shouldn't receive requests.
The distinction matters operationally: a failed liveness check restarts the pod, a failed readiness check just pulls it out of the load balancer until it recovers. Conflating them causes restart loops during transient dependency blips.
app.get("/livez", (req, res) => res.sendStatus(200)); // cheap
app.get("/readyz", async (req, res) => {
try {
await pool.query("SELECT 1"); // real dependency check
res.sendStatus(200);
} catch {
res.sendStatus(503); // pull from LB, don't restart
}
});Three pillars. Structured logs (pino or winston as JSON) with a correlation id per request so you can trace one request across services. Metrics (request rate, error rate, latency percentiles, event loop lag, memory) exported to Prometheus or a hosted APM. Distributed tracing (OpenTelemetry) that follows a request across service boundaries to find where latency actually accrues.
The point the key signal is: log at the boundaries with context, alert on symptoms users feel (error rate, p99 latency) rather than raw resource graphs, and make sure a request id ties logs, metrics, and traces together.
Opening a database connection is expensive (a TCP handshake plus auth), so a per-request connection adds latency and can exhaust the database's connection limit under load. A pool amortizes that by reusing a fixed set of connections. Size it from the database's max connections divided across all your instances, leaving headroom, rather than picking a number blindly.
The counterintuitive part: bigger pools aren't better. Past the point where the database can run queries concurrently, extra connections just queue and add contention. A small pool with fast queries usually beats a large pool that overwhelms the database. Measure query latency under load to find the right size.
N+1 is one query to fetch a list, then one more query per item to fetch its related data: fetch 100 users, then 100 separate queries for each user's orders. It's easy to write with an ORM inside a loop or a naive GraphQL resolver, and it turns one page load into 101 round trips.
Fix it by batching: a single query with a join or a WHERE id IN (...) that fetches all the related rows at once, then stitch them together in memory. In GraphQL, DataLoader batches and caches per-request to collapse the N calls into one. The tell in an interview is spotting a query inside a loop.
// N+1: a query per user
for (const u of users) {
u.orders = await db.query("SELECT * FROM orders WHERE user_id=$1", [u.id]);
}
// batched: one query for all
const ids = users.map((u) => u.id);
const orders = await db.query(
"SELECT * FROM orders WHERE user_id = ANY($1)", [ids]
);Key point: The interview tell is 'a query inside a loop'. If you're given code to review, that pattern is what they planted for you to catch.
The asymmetry causes most pain. ESM can import CommonJS (the default export becomes module.exports), but CommonJS can't require() a package that ships only ESM, which is why 'ERR_REQUIRE_ESM' appears when a dependency goes ESM-only. In ESM there's no require, __dirname, or __filename, so you use createRequire or import.meta.url. And ESM named imports from CommonJS are inferred and sometimes miss, forcing default-import-then-destructure.
The pragmatic answers: for a new project, pick ESM. For a large CommonJS codebase, migrate incrementally or use a bundler. Node's dual-package and conditional exports let a library ship both, which is why authoring one correctly is fiddly.
A readable stream is async-iterable, so you can consume it with for await...of, which reads chunks with automatic backpressure and far less ceremony than wiring 'data', 'end', and 'error' listeners by hand. Errors surface as a normal throw you catch with try/catch.
Prefer async iteration when you're consuming a stream with per-chunk logic and want readable, linear code. Stick with pipe/pipeline when you're composing a pipeline of transforms end to end, where pipeline's error handling and cleanup are the point. They compose: you can iterate the output of a pipeline.
const fs = require("fs");
const readline = require("readline");
const rl = readline.createInterface({
input: fs.createReadStream("huge.log"),
});
for await (const line of rl) { // backpressure handled
process(line);
}Model it as an array of functions, each with the signature (req, res, next). Keep an index, and let next() advance to the function at the next index and invoke it, passing a next that advances again. When a middleware responds instead of calling next(), the chain stops. Wrapping each call in a try/catch (or Promise handling) lets a thrown error skip ahead to error middleware.
The design points the question needs surfaced: middleware runs in order, next() is what passes control (and calling it twice is a bug), and error handling is a separate lane keyed by the four-argument signature. Getting the recursion and the stop condition right is the core of the exercise.
function runChain(middlewares, req, res) {
let i = 0;
function next(err) {
const fn = middlewares[i++];
if (!fn) return;
try {
err ? fn(err, req, res, next) : fn(req, res, next);
} catch (e) {
next(e); // jump toward error middleware
}
}
next();
}Key point: This tests whether you truly understand next(). Narrate the index advance and the stop condition; that's what matters, not perfect syntax.
Clarify the requirements first (per-user or global, burst tolerance, and single-instance versus distributed), then pick the algorithm. A token bucket gives smooth rates with burst capacity. Single instance: keep buckets in an in-process Map keyed by user. Distributed across many Node instances: the state must move to Redis, because per-process memory can't see global traffic. Use an atomic Redis operation (a Lua script, or INCR with EXPIRE) so concurrent requests can't race past the limit.
Close on the operational edges: what to return on rejection (429 with a Retry-After header), how to handle Redis being down (fail open or closed, a real decision), and emitting metrics on rejects. Structuring the answer clarify, algorithm, storage, edges is what the question is really scoring.
// fixed window with an atomic Redis op
async function allow(userId, limit, windowSec) {
const key = `rl:${userId}`;
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, windowSec);
return count <= limit; // 429 + Retry-After when false
}Add the typescript compiler and @types packages for Node and your libraries, configure tsconfig.json (target, module, strict), and either build to JavaScript with tsc for production or run directly in development with tsx or ts-node. Recent Node versions can also strip types and run .ts files without a separate compile step.
What it buys you at scale is catching whole classes of errors before runtime (undefined access, wrong shapes, bad refactors), self-documenting function signatures, and safer refactoring across a large codebase. The cost is build tooling and some friction with loosely typed libraries, which is why teams turn strict on gradually.
Observe first: check error rate, latency percentiles, memory, and event loop lag on your dashboards, and correlate the onset with recent deploys or traffic changes. Read the structured logs and traces around the slow or failing path before touching anything. Is it CPU (event loop blocked), memory (heap climbing toward a crash), a downstream dependency (database slow, timeouts missing), or a specific route?
Then form a hypothesis and verify it with the right tool: py-spy-style live profiling with 0x or clinic for CPU, a heap snapshot diff for memory, loop-delay metrics for blocking. Fix at the layer the evidence points to, ship it, and confirm with the same metrics. The structure, observe, hypothesize, verify, fix, confirm, matters more than any single tool.
Key point: The methodology is; tools support the evidence.
Node.js wins when a service is I/O-bound and you want one language across the stack: JavaScript on the client and the server, a huge npm ecosystem, and an event loop that holds many idle connections cheaply. It trades raw CPU throughput and type safety for that reach and speed of development. Where it loses is heavy computation (the single thread blocks) and teams that want batteries-included concurrency or static types out of the box, where Go or Python fits better. Knowing these trade-offs out loud is itself an interview signal: it shows you pick a runtime on merits, not habit.
| Runtime | Model | Best at | Watch out for |
|---|---|---|---|
| Node.js | Single-threaded, event loop + libuv thread pool | I/O-bound APIs, real-time, streaming, full-stack JS | CPU-bound work blocks the loop; no built-in types |
| Python | Single-threaded per process, GIL | Data, ML, scripting, rapid prototyping | CPU parallelism (GIL); async ecosystem is newer |
| Go | Goroutines on an M:N scheduler | Concurrent services, low latency, CPU work | Smaller data/ML ecosystem; more verbose |
| Deno | Single-threaded event loop, secure by default | Modern TypeScript-first apps, tooling in one binary | Smaller ecosystem; some npm packages need shims |
Prepare in layers, and practice out loud. Most Node.js rounds move from runtime-model questions to live coding to a backend design or debugging discussion, so rehearse each stage rather than only reading answers.
The typical Node.js interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages, with full backend setups for Node.js. These questions reflect what actually gets asked and evaluated inside the interviews we host for 5,000+ HR teams.
See how the AI Coding Interviewer works