MERN Stack · Interview Prep

Node.jsinterview questions & answers

181+ real Node.jsinterview questions with model answers, plus free lessons to learn the concepts. Prepare in English & Hinglish, then practise with an AI mock interview.

20 topics · 181+ questions

Lessons available in both languages

What you’ll learn

  • What is Node.js?
  • Node vs Browser
  • Modules (CommonJS vs ESM)Free account
  • npm & package.jsonFree account
  • Node event loop (libuv)Free account
  • Async patternsFree account
  • EventEmitterFree account
  • Streams & BuffersFree account
  • File system (fs)Free account
  • HTTP moduleFree account
  • process & environmentFree account
  • Error handling in NodeFree account
  • path & os utilitiesFree account
  • Cluster & worker threadsFree account
  • npm scripts & nodemonFree account
  • Debugging Node appsFree account
  • Interview recapFree account
  • Project 1: CLI tool
  • Project 2: Raw HTTP serverFree account
  • Project 3: File-based Todo APIFree account

What is Node.js?

Imagine a restaurant with one super-fast waiter. Instead of standing at each table waiting for the food to cook, the waiter takes your order, hands it to the kitchen, and immediately moves to the next table. When a dish is ready, the kitchen rings a bell and the waiter delivers it. One waiter serves a packed hall — not by being in many places at once, but by never standing around waiting.

In Node.js, the single waiter is the one JavaScript thread. The kitchen is libuv (a C library) with its background thread pool that handles slow work like reading files, network calls, and database queries. The bell is the event loop: when an I/O job finishes, its callback is queued and the single thread picks it up. So Node is single-threaded for your JS, but it offloads the slow I/O to libuv and keeps serving other requests — that's what "non-blocking, event-driven" means.

💡 Runtime = the environment that actually runs your JavaScript — it gives you the engine plus extra built-in tools (files, network). Node is a runtime, not a language and not a framework.

💡 V8 = Google's engine (the same one inside Chrome) that compiles and executes JavaScript. Node embeds V8 so JS can run on a server, outside any browser.

💡 libuv = the C library that gives Node its event loop and a thread pool for async I/O — the "kitchen" doing the slow work in the background.

🌍 Real-world example: An API server gets 5,000 requests, each needing a database read. A traditional one-thread-per-request server would need 5,000 threads. Node fires all 5,000 DB reads at libuv on its single thread and, as each result comes back, runs its callback. Because the waiting happens outside the JS thread, one Node process handles thousands of concurrent connections cheaply — perfect for APIs, chat apps, and real-time dashboards.

Where Node is weak: if you run a heavy CPU task — resizing images, crunching a huge loop, hashing — the single JS thread is busy and can't answer anyone else. The kitchen can't help because the work is pure JS computation, not I/O. That's the classic "don't block the event loop" gotcha; for CPU-heavy work you reach for worker_threads or a different tool.

Where Node fits in MERN: MongoDB, Express, React, Node. React is the frontend in the browser; Node (usually with Express) is the backend — it runs your server, talks to MongoDB, and sends JSON to React. Node is the engine your whole server-side JavaScript runs on.

Standard definition: Node.js is a JavaScript runtime built on Chrome's V8 engine that lets you run JavaScript outside the browser; it is single-threaded, non-blocking, and event-driven, using libuv to offload I/O so it excels at I/O-heavy applications.

const fs = require('fs');

console.log('1: before read');

// Slow I/O is offloaded to libuv; the callback runs later
fs.readFile(__filename, () => {
  console.log('3: file is ready');
});

console.log('2: after read (did not wait!)');

Node vs Browser

Imagine the same actor performing in two different theatres. The actor (their acting skill) is identical — but one theatre has a kitchen backstage, the other has a library. What the actor can reach depends on the building, not on how well they act. JavaScript is that actor. The browser and Node.js are the two theatres: same language, different props lying around.

In Node.js you run the exact same JavaScript language on the exact same V8 engine that Chrome uses. Loops, arrays, promises, Array.map, async/await — all identical. What changes is the host environment: the global objects and built-in APIs each side hands you.

  • Browser gives you: window, document, the whole DOM, fetch, localStorage, alert — things for drawing and interacting with a web page.
  • Node gives you: global, process, require, Buffer, __dirname, __filename, and core modules like fs (files), http (servers), path — things for talking to the operating system, files, and the network. Node has no DOM at all — there is no web page on a server.

💡 Host environment = the program that embeds V8 and adds extra objects/APIs on top of plain JavaScript (the browser, or Node).

💡 DOM = Document Object Model — the tree of a web page (document.querySelector, elements, buttons). It exists only in the browser.

🌍 Real-world example: You write a formatDate() helper using only plain JS (Date, string methods). It runs unchanged in both the browser and Node — that's why validation/formatting logic can be shared. But a function calling document.getElementById(...) throws ReferenceError: document is not defined in Node, and a function calling fs.readFileSync(...) throws in the browser (fs doesn't exist there). Pure-language code travels freely; host-specific code does not.

globalThis is the neutral bridge: it points to window in the browser and to global in Node, so globalThis works in both. Use it when you want to touch the global object without caring which environment you're in.

💡 globalThis = a standard name for "the global object here," whichever host you're running in.

Standard definition: Node.js and the browser both run JavaScript on the V8 engine, but they provide different host globals and APIs — the browser exposes window, document (the DOM), fetch and localStorage, while Node exposes global, process, require, Buffer, __dirname and core modules like fs, http and path, with no DOM; globalThis refers to the global object in either environment.

// Run this with: node file.js
console.log(typeof window);        // browser-only global
console.log(typeof process);       // Node-only global
console.log(process.version);      // Node-only API
console.log(typeof globalThis);    // works in BOTH

Project 1: CLI tool

What we're building: A tiny task CLI — a command-line tool you run in the terminal like node cli.js add "buy milk", node cli.js list, node cli.js remove 2. It saves your tasks to a plain JSON file on disk, so they survive after the program exits. Zero external packages — only Node's built-in process, fs, and path. This teaches the single most important loop in every CLI: read argv → decide what to do → read data file → change it → write data file → print result → exit. Master this and you understand how npm, git, and every other command-line tool is wired.

Concepts it teaches: process.argv (how a program sees what the user typed), subcommand parsing (add / list / remove), reading & writing a JSON file with fs.promises, path.join for a safe file path, printing to stdout with console.log, and exit codes (process.exit(1) = failure, 0 = success).


Step 0 — The mental model (read this first, 30 seconds)

A CLI tool is just a normal Node script. The only new idea is: the user talks to it by typing words after the filename, and those words arrive inside your program as an array called process.argv. Your whole job is: look at that array, figure out what the user wants, do it, print something back, and exit. There's no server, no browser — just argv in, text out, and a file on disk to remember things between runs.

💡 process.argv = an array of the words the user typed on the command line. Node fills it in for you before your code runs.

Keep that picture in your head. Now let's build.


Step 1 — See what the user typed (process.argv)

// cli.js
console.log(process.argv);

Run node cli.js add "buy milk" and you get:

[
  '/usr/local/bin/node',   // [0] the node binary
  '/home/you/cli.js',      // [1] this script's path
  'add',                   // [2] first real argument
  'buy milk'               // [3] second real argument
]

What's happening: Node hands every script a global process object, and process.argv is an array of everything on the command line. The catch that trips up every beginner: the first two slots are always node and the script path. Your actual arguments start at index 2. So we slice them off:

const args = process.argv.slice(2);   // ['add', 'buy milk']
const command = args[0];              // 'add'  → the subcommand
const rest = args.slice(1);           // ['buy milk'] → everything after it

💡 slice(2) drops the first two items (node + script path) so args holds only what the user typed.

Now command tells us what to do and rest is the data for that command. This split is the heart of every CLI.


Step 2 — Decide where the data lives (path.join)

Our tasks need to be saved somewhere so they're still there next time. We'll use a file called tasks.json sitting next to the script.

const path = require('path');
const DATA_FILE = path.join(__dirname, 'tasks.json');

What's happening: __dirname is a Node built-in — the folder this script lives in. path.join glues folder + filename into one correct path (/home/you/tasks.json), using the right slash for the operating system (/ on Mac/Linux, \ on Windows). We *never* build paths by hand with string +, because that breaks across platforms.

💡 __dirname = the absolute folder path of the current file. path.join safely stitches path pieces together for any OS.


Step 3 — Read the JSON file (and handle "file doesn't exist yet")

const fs = require('fs').promises;

async function readTasks() {
  try {
    const raw = await fs.readFile(DATA_FILE, 'utf8');
    return JSON.parse(raw);
  } catch (err) {
    if (err.code === 'ENOENT') return [];   // no file yet → start empty
    throw err;                              // any other error → let it bubble
  }
}

What's happening — trace it:

  1. fs.readFile(DATA_FILE, 'utf8') reads the file. Because we grabbed fs.promises, it returns a promise, so we await it. 'utf8' means "give me a string, not raw bytes."
  2. The file holds text like ["buy milk"]. JSON.parse turns that text into a real JavaScript array.
  3. The gotcha: the very first time you run the tool, tasks.json doesn't exist yet, so readFile throws an error with err.code === 'ENOENT' ("Error NO ENTry"). We catch exactly that case and return an empty array [] — a fresh start. Any other error (say, corrupt permissions) we re-throw, because silently swallowing it would hide real bugs.

💡 ENOENT = the Node/OS error code for "file or folder not found." Checking err.code lets you handle just that case cleanly.


Step 4 — Write the JSON file back

async function writeTasks(tasks) {
  await fs.writeFile(DATA_FILE, JSON.stringify(tasks, null, 2), 'utf8');
}

What's happening: JSON.stringify(tasks, null, 2) turns our array back into text, and the 2 pretty-prints it with 2-space indentation so the file is human-readable. fs.writeFile then overwrites the whole file with that text. (writeFile replaces the file entirely — it doesn't append. If you wanted to add without rewriting, that's fs.appendFile, but for a small task list rewriting the whole thing is simplest and safe.)

💡 JSON.stringify(x, null, 2) = turn a JS value into pretty JSON text, indented by 2 spaces.


Step 5 — The add command (this is the big one — trace it end to end)

async function main() {
  const args = process.argv.slice(2);
  const command = args[0];

  if (command === 'add') {
    const text = args[1];
    if (!text) {
      console.error('Error: please provide a task, e.g. node cli.js add "buy milk"');
      process.exit(1);
    }
    const tasks = await readTasks();
    tasks.push(text);
    await writeTasks(tasks);
    console.log(`Added: "${text}"  (you now have ${tasks.length} task(s))`);
  }
}

main();

Now trace the full journey of node cli.js add "buy milk", step by step — this is the whole tool in one flow:

  1. You type node cli.js add "buy milk" and hit Enter.
  2. Node starts, fills process.argv, and runs your file top to bottom, which calls main().
  3. args = process.argv.slice(2)['add', 'buy milk']. command is 'add'.
  4. The if (command === 'add') branch runs. text = args[1]'buy milk'.
  5. Guard check: text exists, so we skip the error branch. (If the user forgot the text, we'd print an error to stderr and process.exit(1) — a non-zero exit code that tells the shell "this failed.")
  6. await readTasks() — opens tasks.json. First run? It threw ENOENT, so we got back [].
  7. tasks.push('buy milk') — the array is now ['buy milk'].
  8. await writeTasks(tasks)JSON.stringify turns it into text and fs.writeFile saves it to disk. Your task is now permanent.
  9. console.log(...) prints the confirmation to stdout — the text you see in the terminal.
  10. main() finishes, there's nothing left to do, so Node exits on its own with code 0 (success).

The key insight: the program did four things in order — parse argv → read file → mutate array → write file → print. Every subcommand is a variation of this exact sequence.


Step 6 — The list and remove commands

  else if (command === 'list') {
    const tasks = await readTasks();
    if (tasks.length === 0) {
      console.log('No tasks yet. Add one with: node cli.js add "..."');
    } else {
      tasks.forEach((t, i) => console.log(`${i + 1}. ${t}`));
    }
  }

  else if (command === 'remove') {
    const index = Number(args[1]) - 1;   // user types 1-based, arrays are 0-based
    const tasks = await readTasks();
    if (Number.isNaN(index) || !tasks[index]) {
      console.error(`Error: no task at position ${args[1]}`);
      process.exit(1);
    }
    const [removed] = tasks.splice(index, 1);
    await writeTasks(tasks);
    console.log(`Removed: "${removed}"`);
  }

  else {
    console.error(`Unknown command: ${command || '(none)'}`);
    console.error('Usage: add <text> | list | remove <number>');
    process.exit(1);
  }

What's happening:

  • list is read-only: read the file, and either say "no tasks yet" or loop with forEach, printing each task with a 1-based number (i + 1) so it reads naturally for a human.
  • remove takes a number. Users think in 1-based positions ("remove task 2"), but arrays are 0-based, so we do Number(args[1]) - 1. We validate: if the input isn't a number (Number.isNaN) or there's no task there, we error out with exit code 1. Otherwise splice(index, 1) cuts that one item out, we save, and confirm.
  • The else (default) branch catches typos like node cli.js addd. Printing a usage hint and exiting non-zero is what makes a CLI feel polished — the same thing git does when you mistype a command.

💡 1-based vs 0-based: humans count from 1, arrays index from 0. Off-by-one bugs live exactly here — always convert at the boundary.


Step 7 — Make it run like a real command (shebang + bin)

Right now you type node cli.js add "...". Real tools like npm you just type npm .... Two small pieces do that:

1. The shebang — the very first line of cli.js:

#!/usr/bin/env node

This line tells the operating system "run this file with node." /usr/bin/env node means "find node on the user's PATH and use it" — portable across machines. (On Mac/Linux you'd also chmod +x cli.js to mark it executable.)

2. The bin field in package.json:

{
  "name": "task-cli",
  "version": "1.0.0",
  "bin": { "task": "cli.js" }
}

bin maps a command name (task) to your file. After npm link (or when a user installs your package), Node puts a task command on the PATH pointing at cli.js. Now task add "buy milk" works from anywhere — no node, no path. That's literally how tools like nodemon, create-react-app, and eslint become terminal commands.

💡 shebang = the #!... first line that tells the OS which interpreter runs the file. bin field = maps a command name to a script so it becomes a real terminal command.


🔎 The full flow (recap the wiring)

Here's the finished tool, command by command:

  1. node cli.js add "buy milk"argv.slice(2) = ['add','buy milk']command='add'readTasks() (ENOENT → []) → push('buy milk')writeTasks saves ["buy milk"] → prints Added: "buy milk" → exit 0.
  2. node cli.js listcommand='list'readTasks()['buy milk']forEach prints 1. buy milk → exit 0.
  3. node cli.js remove 1command='remove'index = 1 - 1 = 0readTasks()splice(0,1) cuts 'buy milk'writeTasks saves [] → prints Removed: "buy milk" → exit 0.
  4. node cli.js oops → no branch matches → else prints usage → process.exit(1) (failure).

Every command follows the same beats: parse argv → read the JSON file → change the array → write it back → print → exit with the right code.


✅ What you just learned

  • process.argv — how a CLI sees the user's words; real arguments start at index 2, so .slice(2).
  • Subcommand parsingargs[0] is the command, the rest is its data; an if/else (or switch) routes to each action.
  • fs.promises read/writeawait fs.readFile(path,'utf8') + JSON.parse to load, JSON.stringify + fs.writeFile to save; handle first-run ENOENT by returning [].
  • path.join(__dirname, ...) — build a safe, cross-platform path instead of gluing strings.
  • stdout vs stderr & exit codesconsole.log for results, console.error + process.exit(1) for failures so scripts can detect them.
  • shebang + bin — the two pieces that turn node cli.js into a real task command on the PATH.
  • The universal CLI loopargv → read → change → write → print → exit. Every command-line tool is built on this.
#!/usr/bin/env node
const fs = require('fs').promises;
const path = require('path');

const DATA_FILE = path.join(__dirname, 'tasks.json');

async function readTasks() {
  try {
    const raw = await fs.readFile(DATA_FILE, 'utf8');
    return JSON.parse(raw);
  } catch (err) {
    if (err.code === 'ENOENT') return [];
    throw err;
  }
}

async function writeTasks(tasks) {
  await fs.writeFile(DATA_FILE, JSON.stringify(tasks, null, 2), 'utf8');
}

async function main() {
  const args = process.argv.slice(2);
  const command = args[0];

  if (command === 'add') {
    const text = args[1];
    if (!text) {
      console.error('Error: please provide a task, e.g. node cli.js add "buy milk"');
      process.exit(1);
    }
    const tasks = await readTasks();
    tasks.push(text);
    await writeTasks(tasks);
    console.log(`Added: "${text}"  (you now have ${tasks.length} task(s))`);
  } else if (command === 'list') {
    const tasks = await readTasks();
    if (tasks.length === 0) {
      console.log('No tasks yet. Add one with: node cli.js add "..."');
    } else {
      tasks.forEach((t, i) => console.log(`${i + 1}. ${t}`));
    }
  } else if (command === 'remove') {
    const index = Number(args[1]) - 1;
    const tasks = await readTasks();
    if (Number.isNaN(index) || !tasks[index]) {
      console.error(`Error: no task at position ${args[1]}`);
      process.exit(1);
    }
    const [removed] = tasks.splice(index, 1);
    await writeTasks(tasks);
    console.log(`Removed: "${removed}"`);
  } else {
    console.error(`Unknown command: ${command || '(none)'}`);
    console.error('Usage: add <text> | list | remove <number>');
    process.exit(1);
  }
}

main();

Node.jsinterview questions & answers

10 sample questions below — 181+ in the full bank inside.

How do you export and import in CommonJS?

In CommonJS you attach things to module.exports (or the shortcut exports) to make them available, and you pull them in with require(). It is Node's historical default module system and works synchronously.

In simple terms: CommonJS is like handing someone a filled basket: you put items in module.exports, and the other file calls require to grab that basket. Example: greet.js has module.exports = function() { return 'hi' }; and index.js has const greet = require('./greet'); greet();

What does path.extname return?

path.extname returns the file extension of a path, including the leading dot, taken from the last dot in the last path segment. If there is no extension it returns an empty string.

In simple terms: It's like checking a file's surname to know its family — .jpg means image, .pdf means document. Example: path.extname('photo.jpg') → '.jpg', path.extname('archive.tar.gz') → '.gz', and path.extname('README') → '' (empty).

How do you handle errors when using async/await?

Wrap the await calls in a try/catch block. If the awaited promise rejects, the rejection is thrown like a normal error and the catch block catches it. This makes async error handling read like synchronous code.

In simple terms: try/catch is like a safety net under a trapeze artist — if he falls (the promise rejects), the net catches him instead of him hitting the floor (crashing). Example: try { const data = await readFile('a.txt'); } catch (err) { console.error('failed:', err); }.

What do .on() and .emit() do?

.on(eventName, listener) subscribes: it registers a callback to run whenever that event happens. .emit(eventName, ...args) publishes: it fires the event and calls every registered listener, passing along any extra arguments you provide.

In simple terms: It's like a WhatsApp group: .on() is you joining the group so you get every message, .emit() is someone posting a message that all members instantly receive. Example: emitter.on('data', (x) => console.log(x)); emitter.emit('data', 42) prints 42, because the value passed to emit reaches the listener.

What is a Promise in JavaScript/Node?

A Promise is an object representing the eventual result of an async operation. It has three states — pending, fulfilled, or rejected — and lets you attach .then() for success and .catch() for failure instead of nesting callbacks. Promises are chainable, which flattens sequential async steps.

In simple terms: Think of a Promise like a restaurant token you get after ordering — you don't have the food yet (pending), but the token promises it's coming; later it turns into your meal (fulfilled) or a 'sorry, out of stock' (rejected). Example: fetchUser().then(u => console.log(u)).catch(e => console.error(e)).

How do you handle a rejected promise without async/await?

Attach a .catch() handler to the promise chain. .then() handles success and .catch() handles any rejection that happens anywhere earlier in the chain. Forgetting .catch() leaves the rejection unhandled.

In simple terms: A promise chain is like a relay race — .then is passing the baton forward on success, and .catch is the coach standing at the end to grab the runner if anyone trips. Example: readFile('a.txt').then(data => use(data)).catch(err => console.error(err)).

What does async/await do?

async/await is syntactic sugar over Promises that lets you write asynchronous code that reads like synchronous code. An async function always returns a Promise, and await pauses inside it until a Promise settles, giving you the resolved value directly. You wrap awaits in try/catch to handle rejections.

In simple terms: It's like waiting in a queue instead of taking a token and walking away — you stand there (await) until you're served, then continue with your food in hand. Cleaner to follow top-to-bottom. Example: async function go(){ try { const u = await fetchUser(); console.log(u); } catch(e){ console.error(e); } }.

What is the error-first callback convention in Node.js?

It is Node's standard callback shape where the first argument is always the error (or null if none) and the data comes after: (err, data) => {}. You check err first; if it's truthy you handle the failure, otherwise you use the data. Core APIs like fs.readFile follow this pattern.

In simple terms: Think of it like a delivery guy who always tells you 'bad news first' — 'package damaged?' before handing you the box. So you always deal with the problem before touching the goods. Example: fs.readFile('a.txt', (err, data) => { if (err) return console.error(err); console.log(data.toString()); }).

What is callback hell?

Callback hell is when you nest many dependent async callbacks inside each other, creating a deep pyramid of code that drifts to the right and becomes hard to read and error-handle. It happens because each step waits for the previous one's callback. Promises and async/await were introduced to flatten this.

In simple terms: Imagine giving instructions inside instructions inside instructions — 'when you reach the shop, when you find bread, when it's fresh, then buy it' — each nested deeper. That right-drifting 'pyramid of doom' is callback hell. Example: readFile(a, () => { readFile(b, () => { readFile(c, () => { /* deeply nested */ }) }) }).

Node is single-threaded, so how many CPU cores does one Node process use by default?

By default one Node process runs your JavaScript on a single thread, so it uses only one CPU core no matter how many cores the machine has. To use the other cores you must run multiple processes (cluster) or spin up worker threads.

In simple terms: Imagine a shop with 4 billing counters but only 1 cashier — 3 counters sit empty. That's default Node: an 8-core server, but your JS runs on one core. Example: on an 8-core box a plain Express app still uses ~1 core; the other 7 are idle until you cluster or use worker_threads.

171+ more Node.js questions inside

Create a free account to read the full question bank, learn every topic, and practise with an AI mock interview.

Unlock all questions — free

Ready to practise Node.js?

Unlock every topic free, then face an AI interviewer that asks follow-ups and grades your answers.