The 25 Node.js interview questions that come up most - event loop, streams, clustering, Express middleware, and scaling - each with a direct answer, code, and an interview tip.
Skip the 50-question deep dive when you're short on time. This is the shortlist: the 25 Node.js interview questions that show up most often for junior, mid-level, and full-stack roles, answered directly with working code and the follow-up an interviewer is likely to ask next.
Each answer is built the way interviewers actually listen: a direct answer first, then the code or table that proves you understand it, then the follow-up question that tends to come next.
For a deeper dive into any single topic below, see our complete Node.js interview questions guide, which covers every tier from basic through advanced.
Every Node.js interview - regardless of seniority - opens somewhere in this section. Interviewers use these six questions to confirm you understand why Node.js behaves the way it does, not just how to call its APIs.
Node.js is a JavaScript runtime built on Google's V8 engine that lets JavaScript run outside the browser - on a server, with access to the file system, network, and OS-level APIs. It's single-threaded by design: one thread runs your JavaScript, which avoids the locking, deadlocks, and race conditions that come with managing shared memory across multiple threads.
const http = require("http");
const server = http.createServer((req, res) => {
res.end("Handled on a single JS thread");
});
server.listen(3000);
// Thousands of concurrent connections, one thread - because I/O is offloaded, not the JS executionInterview tip: Immediately clarify that "single-threaded" refers to the JavaScript execution thread only. Node.js still uses a multi-threaded C++ layer (libuv) underneath for I/O - that distinction is Q5 below, and interviewers often ask it as a direct follow-up.
The event loop is the mechanism that lets a single-threaded runtime handle thousands of concurrent operations without blocking. It cycles through fixed phases - timers, pending callbacks, poll, check, and close callbacks - executing any callbacks that are ready in each phase before moving to the next.
setTimeout(() => console.log("timers phase"), 0);
setImmediate(() => console.log("check phase"));
Promise.resolve().then(() => console.log("microtask"));
process.nextTick(() => console.log("nextTick"));
// Output: nextTick → microtask → timers phase → check phaseInterview tip: Be ready to draw the phases from memory. Interviewers often skip straight to "what phase does setImmediate run in?" - knowing the full cycle makes that answer automatic.
Non-blocking I/O means Node.js starts an operation - reading a file, querying a database, calling an API - and immediately continues executing the next line of code, instead of waiting for that operation to finish. The result comes back later, through a callback, Promise, or await.
const fs = require("fs");
fs.readFile("data.json", "utf8", (err, data) => {
console.log("File finished reading");
});
console.log("This logs first - Node didn't wait for the file");Interview tip: Contrast this directly with fs.readFileSync(), which blocks the thread. Interviewers use this pair to check whether you understand that blocking calls freeze every request Node is handling, not just the current one.
Node.js never actually does I/O work on the JavaScript thread. When your code calls something like a file read or a network request, that work is handed off to the OS kernel or to libuv's background thread pool. The JS thread stays free to keep processing other requests, and the event loop picks up the result once it's ready.
// Four "simultaneous" reads - none block the main thread
["a.txt", "b.txt", "c.txt", "d.txt"].forEach((file) => {
fs.readFile(file, () => console.log(`${file} done`));
});
console.log("All four reads dispatched - main thread still free");Interview tip: Say the word "libuv" out loud. Interviewers use it as a signal that you've gone one layer past the marketing pitch of "Node.js is non-blocking."
libuv is the C library underneath Node.js that provides the event loop itself, plus a thread pool (default size 4) for operations that can't be done asynchronously at the OS level - things like fs file operations, DNS lookups, and some crypto functions. Network I/O, by contrast, is handled by the OS's native async mechanisms without needing the thread pool at all.
process.env.UV_THREADPOOL_SIZE = 8; // must be set before any threadpool-using callInterview tip: A common trick question: "Is all Node.js I/O non-blocking at the OS level?" No - file system operations rely on libuv's thread pool because most operating systems don't offer true async file I/O.
Microtasks - Promise callbacks and process.nextTick() - run immediately after the current operation finishes, before the event loop moves to its next phase. Macrotasks - setTimeout, setImmediate, I/O callbacks - are tied to specific event loop phases and only run once the microtask queue is fully drained.
console.log("1");
setTimeout(() => console.log("2 - macrotask"), 0);
Promise.resolve().then(() => console.log("3 - microtask"));
console.log("4");
// Output: 1 → 4 → 3 → 2Interview tip: Recursively scheduling microtasks (chained .then() calls with no macrotask in between) can starve the event loop entirely - nothing in the timers or I/O phases runs until the microtask queue empties. It's a subtle bug worth mentioning if asked about performance issues.
Once fundamentals are covered, interviewers move to how you actually structure async code day to day - timers, custom events, and the callback-to-Promise evolution.
setImmediate() runs in the check phase, right after the poll phase completes on the current loop iteration. setTimeout(fn, 0) runs in the timers phase, which comes earlier in the cycle - but only after a minimum delay of at least 1ms. Inside an I/O callback, setImmediate() always fires before setTimeout(fn, 0).
const fs = require("fs");
fs.readFile(__filename, () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
// Inside I/O callback: "immediate" always logs first
});Interview tip: Outside an I/O callback (at the top level of a script), the order between the two is not guaranteed - it depends on process performance. Say that explicitly; it shows you know the nuance rather than a memorized rule.
process.nextTick() queues a callback to run immediately after the current operation completes - before the event loop continues to any phase, and even before Promise microtasks. It's useful for deferring work until after the current synchronous block finishes, such as emitting an event right after a constructor runs, without making the caller wait.
const { EventEmitter } = require("events");
class Task extends EventEmitter {
constructor() {
super();
process.nextTick(() => this.emit("ready")); // listeners can attach first
}
}
const t = new Task();
t.on("ready", () => console.log("ready fired safely"));Interview tip: Warn about overuse. Recursive process.nextTick() calls can starve the event loop completely - worse than recursive microtasks, since nextTick has priority over everything else.
EventEmitter is a core Node.js class that implements the publish-subscribe pattern: objects emit named events, and any number of listener functions can subscribe to react when that event fires. It's the foundation underneath streams, HTTP servers, and most of Node's core async APIs.
const { EventEmitter } = require("events");
const orders = new EventEmitter();
orders.on("placed", (id) => console.log(`Order ${id} received`));
orders.on("placed", (id) => console.log(`Sending confirmation for ${id}`));
orders.emit("placed", 1024);
// Both listeners fire, synchronously, in registration orderInterview tip: Mention that listeners run synchronously in the order they were registered, and that EventEmitter has no built-in error handling - an unhandled "error" event throws and can crash the process.
Callback hell is what happens when multiple dependent async operations are nested inside each other's callbacks, producing deeply indented code that's hard to read, debug, or modify. The fix is flattening the structure with Promises or, better, async/await.
// Callback hell
getUser(id, (user) => {
getOrders(user.id, (orders) => {
getInvoice(orders[0].id, (invoice) => {
console.log(invoice); // three levels deep, error handling triplicated
});
});
});
// Flattened with async/await
async function getInvoiceForUser(id) {
const user = await getUser(id);
const orders = await getOrders(user.id);
return getInvoice(orders[0].id);
}Interview tip: Naming the problem before being asked ("this is what's known as callback hell") signals you've dealt with legacy code, not just greenfield projects.
async/await is syntax built on top of Promises that lets asynchronous code read like synchronous code. An async function always returns a Promise; await pauses execution inside that function until the awaited Promise settles, without blocking the rest of the application.
async function fetchInvoice(orderId) {
try {
const order = await db.orders.findById(orderId);
const invoice = await billing.generate(order);
return invoice;
} catch (err) {
logger.error("Invoice generation failed", err);
throw err;
}
}Interview tip: Point out that error handling moves into standard try/catch, replacing chained .catch() calls - and that Promise.all() is still the right tool when you need several awaits to run in parallel rather than sequentially.
These three questions test whether you understand how Node.js handles data that's too large - or too raw - to sit comfortably in a single JavaScript object.
Streams let you process data as a sequence of chunks instead of loading the entire payload into memory at once. Node.js has four types: Readable (a source), Writable (a destination), Duplex (both), and Transform (a duplex stream that modifies data as it passes through).
const fs = require("fs");
const zlib = require("zlib");
fs.createReadStream("large-log.txt")
.pipe(zlib.createGzip())
.pipe(fs.createWriteStream("large-log.txt.gz"));
// Compresses a multi-GB file without ever holding it fully in RAMInterview tip: Streams are the answer whenever an interview scenario mentions "large file" or "real-time data" - naming them unprompted is a strong signal.
A Buffer is a fixed-size block of raw memory allocated outside the V8 heap, used to hold binary data - file contents, image bytes, TCP packets. Unlike a regular JavaScript array, a Buffer's size can't change after creation, and it stores raw bytes rather than typed JS values.
const buf = Buffer.from("Node.js", "utf8");
console.log(buf); // <Buffer 4e 6f 64 65 2e 6a 73>
console.log(buf.length); // 7 - byte length, not character count
console.log(buf.toString()); // "Node.js"Interview tip: Know the difference between Buffer.alloc() (zero-filled, safe) and Buffer.allocUnsafe() (faster, but may contain leftover memory) - interviewers use this to check security awareness, not just API knowledge.
Backpressure happens when a writable stream can't consume data as fast as a readable stream produces it. .pipe() handles this automatically by pausing the source when the destination's internal buffer fills up, and resuming once it drains - but manual stream handling needs to respect the same signal.
const readable = fs.createReadStream("huge-file.txt");
const writable = fs.createWriteStream("out.txt");
readable.on("data", (chunk) => {
const canContinue = writable.write(chunk);
if (!canContinue) {
readable.pause();
writable.once("drain", () => readable.resume());
}
});PERSONAL EXPERIENCE -
Interview tip: This question separates candidates who've only used .pipe() from those who've debugged a memory leak caused by ignoring backpressure in a manual stream implementation. If you've hit this in production, say so - it's a strong answer.
Full-stack and mid-level rounds increasingly probe how you'd take a single Node.js process into production at scale - this is where that shows up.
Node.js runs on a single thread by default, which means one process can only use one CPU core. The cluster module forks multiple copies of your application as separate child processes, all sharing the same server port, so incoming connections get load-balanced across every CPU core on the machine.
const cluster = require("cluster");
const os = require("os");
if (cluster.isPrimary) {
os.cpus().forEach(() => cluster.fork());
} else {
require("./server"); // each worker runs its own copy
}Interview tip: Clarify that cluster workers don't share memory - each has its own event loop and heap. State needs to live in something external, like Redis, not in a module-level variable.
workerthreads run JavaScript in parallel within the same_ process, and - unlike clustered processes - can share memory directly through SharedArrayBuffer. They're built for CPU-intensive work (image processing, large computations) that would otherwise block the event loop, whereas clustering is built for scaling I/O-bound request handling across cores.
const { Worker } = require("worker_threads");
const worker = new Worker("./heavy-computation.js", {
workerData: { input: 42 },
});
worker.on("message", (result) => console.log("Result:", result));Interview tip: The distinction interviewers want: cluster for scaling concurrent requests, worker threads for offloading CPU-bound work so it doesn't block the main event loop.
Scaling a Node.js app usually combines several layers: clustering or a process manager (like PM2) to use every core on a box, a load balancer to distribute traffic across multiple machines, horizontal scaling via containers and orchestration (Docker, Kubernetes), and offloading CPU-heavy work to worker threads or separate services entirely.
// PM2 ecosystem file - cluster mode across all available cores
module.exports = {
apps: [
{
script: "server.js",
instances: "max",
exec_mode: "cluster",
},
],
};Interview tip: Mention statelessness explicitly - horizontal scaling only works cleanly if your app doesn't rely on in-memory session state that a load balancer might route around.
CommonJS (require/module.exports) is Node's original module system - it resolves and loads modules synchronously at runtime, which allows conditional require() calls. ES Modules (import/export) are statically analyzed at parse time, before any code runs, which enables tree-shaking but forbids conditional imports at the top level.
// CommonJS
if (process.env.NODE_ENV === "development") {
const debug = require("./debug-tools");
}
// ES Modules - needs dynamic import() for conditional loading
if (process.env.NODE_ENV === "development") {
const debug = await import("./debug-tools.js");
}Interview tip: Know what controls which system Node assumes: .mjs forces ESM, .cjs forces CommonJS, and a plain .js file follows the "type" field in the nearest package.json.
Almost every full-stack Node.js role runs on Express or something shaped like it. These four questions cover the parts of that layer interviewers ask about most.
Middleware is a function with access to the request, response, and a next() callback, executed in order for every matching request. Each middleware can modify the request/response, end the cycle, or call next() to pass control to the following middleware - which is how logging, auth, and validation get layered onto routes.
const express = require("express");
const app = express();
function requireAuth(req, res, next) {
if (!req.headers.authorization) {
return res.status(401).json({ error: "Unauthorized" });
}
next(); // pass control to the next middleware/route handler
}
app.get("/dashboard", requireAuth, (req, res) => res.send("Welcome"));Interview tip: Know the four-argument signature (err, req, res, next) - Express recognizes it specifically as error-handling middleware and skips straight to it when next(err) is called.
CORS (Cross-Origin Resource Sharing) is a browser security mechanism that blocks a web page from making requests to a different origin unless the server explicitly allows it via response headers. In Express, this is usually handled with the cors middleware, which sets headers like Access-Control-Allow-Origin based on your configuration.
const cors = require("cors");
app.use(
cors({
origin: ["https://app.example.com"],
credentials: true,
}),
);Interview tip: Clarify that CORS is enforced by the browser, not the server - a server-to-server request or a tool like curl ignores CORS entirely, which is a common point of confusion.
Node.js error handling depends on the pattern you're using: error-first callbacks check (err, data) as the first argument, Promises use .catch() or try/catch inside async/await, and Express centralizes error handling with dedicated error-handling middleware. At the process level, uncaughtException and unhandledRejection listeners catch anything that slips through.
process.on("unhandledRejection", (reason) => {
logger.error("Unhandled rejection:", reason);
});
app.use((err, req, res, next) => {
logger.error(err);
res.status(500).json({ error: "Something went wrong" });
});Interview tip: Process-level handlers are a safety net for logging, not a substitute for handling errors close to where they occur - say that distinction explicitly if asked.
Environment variables are key-value pairs supplied by the operating system or deployment platform, accessed in Node via process.env. They're the standard way to inject configuration - API keys, database URLs, feature flags - without hardcoding secrets into source code.
require("dotenv").config();
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) throw new Error("DATABASE_URL is not set");Interview tip: Never commit a .env file with real secrets - mention .env.example for documenting required variables and a secrets manager (AWS Secrets Manager, Vault) for production credentials.
The last three questions test whether you can reason about Node.js as a tool with tradeoffs, rather than a technology to defend unconditionally - a signal interviewers weigh heavily for mid-level and full-stack candidates.
package-lock.json records the exact resolved version of every package in your dependency tree, including nested dependencies. Without it, two installs of the same package.json - on different machines or at different times - could resolve slightly different versions, causing inconsistent behavior. Committing it guarantees everyone gets an identical dependency tree.
npm install # respects package-lock.json if present
npm ci # strict install - fails if package.json and lock file disagreeInterview tip: Recommend npm ci for CI/CD pipelines specifically - it's faster and fails loudly on any mismatch, instead of silently resolving new versions.
Node.js is strongest for I/O-heavy, concurrent workloads: REST and GraphQL APIs, real-time apps (chat, live dashboards) built on WebSockets, streaming services, microservices, and CLI tooling. Its non-blocking model lets a single process handle a large number of simultaneous connections efficiently.
const { Server } = require("socket.io");
const io = new Server(3000);
io.on("connection", (socket) => {
socket.on("message", (msg) => io.emit("message", msg)); // real-time broadcast
});Interview tip: If asked "when would you not use Node.js," that's really Q25 - pair the two answers together to show you understand the tradeoff, not just the sales pitch.
Node.js struggles with CPU-intensive, blocking work - heavy computation, image/video processing, or large synchronous loops - because that work occupies the single JS thread and freezes every other request until it finishes. It's also a less natural fit for complex relational systems with heavy multi-table transactions, where a language with mature ORM and transaction tooling may fit better.
// Bad: blocks the event loop for every connected client
function blockingFib(n) {
return n < 2 ? n : blockingFib(n - 1) + blockingFib(n - 2);
}
// Better: offload to a worker thread, keep the main thread freeInterview tip: The strongest answer names the mitigation, not just the limitation - worker threads for CPU-bound tasks, or offloading heavy computation to a separate service entirely.
Work through the fundamentals first - the event loop, non-blocking I/O, and the difference between the JS thread and libuv's thread pool - since nearly every round starts there. Then practice tracing code output (like the setTimeout/setImmediate/nextTick ordering above) out loud, since interviewers often ask you to predict output rather than define terms.
At the 2-3 year mark, expect fewer pure definitions and more "why" and "how would you" questions: why choose clustering over worker threads, how you'd scale an API under load, and how error handling is structured across an Express app. Questions 15-22 above map directly to that band.
Full-stack interviews spend more time on Express middleware, CORS, environment/secrets management, and how the API layer talks to a database - the practical plumbing between frontend and backend. Pure backend rounds tend to spend more time on runtime internals like the event loop and streams.
Twenty-five is enough to cover a junior-to-mid interview end to end without cramming. If you're interviewing for a senior or staff role, add the advanced Node.js interview guide, which covers production troubleshooting, security, and system design judgment this list doesn't touch.
This is a shortlist - the 25 questions asked most often, meant to be finished before a single interview. A comprehensive guide, like our 50-question Node.js guide, grades every question by difficulty tier and goes deeper into each topic with more examples.
These 25 questions map to what actually gets asked across junior, mid-level, and full-stack Node.js interviews - the event loop and concurrency model, async patterns, streams and buffers, and the scaling decisions that separate someone who's used Node.js from someone who understands it.
Work through each answer until you can explain it without the code in front of you, then trace the tricky output-ordering examples (Q2, Q6, Q7) out loud. That's usually the difference between a good answer and a confident one.
Ready to go further? See the complete 50-question Node.js guide for every tier from basic to advanced, or jump straight to the advanced Node.js interview guide for production troubleshooting, security, and staff-level system design questions.