1. What is Node.js?
Node.js is an open-source, cross-platform JavaScript runtime built on Chrome's V8 engine that lets you run JavaScript outside the browser. It is commonly used for servers, APIs, CLI tools and build tooling. Its event-driven, non-blocking I/O model makes it efficient for I/O-heavy workloads such as web servers and real-time apps.
2. How does Node.js work? Explain the single-threaded event-driven model.
Node.js runs your JavaScript on a single main thread and uses an event loop to handle many concurrent operations without blocking. When you start an I/O task such as a file read or network call, Node hands it to the operating system or to the libuv thread pool and keeps running other code. When the task finishes, its callback is queued and the event loop runs it on the main thread.
3. What is the event loop in Node.js?
The event loop is the mechanism that lets Node.js perform non-blocking operations by repeatedly picking completed tasks from queues and running their callbacks on the main thread. It is implemented by libuv and cycles through phases: timers, pending callbacks, poll (I/O), check (setImmediate) and close callbacks. Between callbacks, Node drains the process.nextTick queue and then the promise microtask queue.
4. What is libuv and what role does it play?
libuv is a C library that gives Node.js its event loop, asynchronous I/O and cross-platform abstraction over the operating system. It uses native async APIs such as epoll, kqueue and IOCP for networking, and a thread pool (4 threads by default) for work that has no async OS API, like file system operations, DNS lookups via getaddrinfo, and some crypto functions. You can resize the pool with UV_THREADPOOL_SIZE.
5. What is the difference between blocking and non-blocking code in Node.js?
Blocking code stops the main thread until the operation finishes, while non-blocking code starts the operation and continues, running a callback or resolving a promise later. For example, fs.readFileSync blocks every other request until the file is read, but fs.readFile does not. In a server you should avoid blocking calls and CPU-heavy loops because they stall the whole event loop.
const fs = require('fs');
const data = fs.readFileSync('big.txt');
console.log('after sync read');
fs.readFile('big.txt', (err, d) => console.log('async done'));
console.log('runs before async done');
6. What is the difference between CommonJS and ES modules in Node.js?
CommonJS uses require() and module.exports, loads modules synchronously and is the traditional Node format, while ES modules use import and export, are statically analyzable, load asynchronously and are the JavaScript standard. Node treats a file as ESM if it ends in .mjs or the nearest package.json has "type": "module". ESM supports top-level await and has no __dirname or require by default; use import.meta.dirname or import.meta.url instead.
const fs = require('fs');
module.exports = { run };
import fs from 'node:fs';
export function run() {}
7. How does `require()` work and how does module caching behave?
require() resolves the path, loads and wraps the file in a function, executes it once and returns its module.exports. The result is cached in require.cache by resolved filename, so later calls to require for the same file return the same object without re-running the code. This is why modules behave like singletons, and why circular dependencies can return partially filled exports.
8. What is npm and what is it used for?
npm is the default package manager for Node.js and also the name of the largest public registry of JavaScript packages. It installs dependencies, runs scripts defined in package.json, manages versions via package-lock.json and publishes packages. Common commands are npm install, npm run, npm update and npm publish. Alternatives such as pnpm and Yarn use the same registry.
9. What is `package.json` and what are its important fields?
package.json is the manifest of a Node.js project, describing its metadata, dependencies and scripts. Key fields include name, version, main or exports (entry points), type (commonjs or module), scripts, dependencies, devDependencies, engines and bin. npm init creates one, and npm install reads it to know what to fetch.
{
"name": "my-app",
"version": "1.0.0",
"type": "module",
"main": "index.js",
"scripts": { "start": "node index.js" },
"dependencies": { "express": "^4.19.0" }
}
10. What is the difference between `dependencies`, `devDependencies` and `peerDependencies`?
dependencies are packages required at runtime, devDependencies are only needed for development such as tests, linters and build tools, and peerDependencies declare that the host project must provide a compatible version of a package, which is common for plugins. Install dev-only packages with npm install -D. Running npm install --omit=dev in production skips devDependencies.
11. What is the purpose of `package-lock.json`, and what do `^` and `~` mean in versions?
package-lock.json records the exact resolved version of every installed package, including transitive ones, so installs are reproducible across machines. In a semver range, ^1.2.3 allows minor and patch updates (up to below 2.0.0), while ~1.2.3 allows only patch updates (up to below 1.3.0). Use npm ci in CI to install strictly from the lock file.
12. What are callbacks and what is callback hell?
A callback is a function passed to another function to be called when an asynchronous operation completes, and Node core APIs traditionally use the error-first convention (err, result). Callback hell is deeply nested callbacks that make code hard to read and error handling repetitive. Promises and async/await are the usual fixes.
fs.readFile('a.txt', (err, a) => {
if (err) return console.error(err);
fs.readFile('b.txt', (err, b) => {
if (err) return console.error(err);
console.log(a + b);
});
});
13. What are Promises and how do `async/await` work in Node.js?
A Promise represents a value that will be available later and is either pending, fulfilled or rejected. async functions always return a promise, and await pauses that function until a promise settles while the event loop keeps running other work. Use try/catch for errors and Promise.all to run independent operations in parallel.
import { readFile } from 'node:fs/promises';
async function load() {
const [a, b] = await Promise.all([readFile('a.txt', 'utf8'), readFile('b.txt', 'utf8')]);
return a + b;
}
14. What is the difference between `process.nextTick()` and `setImmediate()`?
process.nextTick() queues a callback to run right after the current operation finishes, before the event loop continues, whereas setImmediate() runs its callback in the check phase of the next loop iteration, after I/O events. So nextTick fires earlier and, if called recursively, can starve I/O. Promise callbacks run after the nextTick queue is drained.
setImmediate(() => console.log('immediate'));
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
15. What is the difference between `setTimeout(fn, 0)` and `setImmediate()`?
Both schedule a callback for after the current code, but setTimeout(fn, 0) runs in the timers phase once at least 1 ms has elapsed, while setImmediate runs in the check phase right after the poll phase. From the main module the order is non-deterministic and depends on process performance. Inside an I/O callback, setImmediate always runs first.
16. What are streams in Node.js and what types exist?
Streams are objects that let you read or write data piece by piece instead of loading it all into memory, which is ideal for large files and network data. The four types are Readable, Writable, Duplex (both directions, like a TCP socket) and Transform (a duplex that modifies data, like gzip). Streams are EventEmitters and support backpressure so a fast producer does not overwhelm a slow consumer.
import { pipeline } from 'node:stream/promises';
import fs from 'node:fs';
import zlib from 'node:zlib';
await pipeline(fs.createReadStream('in.txt'), zlib.createGzip(), fs.createWriteStream('in.txt.gz'));
17. What is a Buffer in Node.js?
A Buffer is a fixed-size chunk of raw binary memory allocated outside the V8 heap, used to handle bytes from files, sockets and streams. It is a subclass of Uint8Array. You create buffers with Buffer.from() or Buffer.alloc() and convert them with methods like toString('utf8') or toString('base64'). The old new Buffer() constructor is deprecated.
18. What is EventEmitter and how do you use it?
EventEmitter from the events module is the base class of Node's event-driven architecture, letting objects emit named events and register listeners with on(), once() and off(). Listeners run synchronously in the order they were registered when emit() is called. Many core objects such as streams, servers and processes extend it. An unhandled 'error' event throws.
import { EventEmitter } from 'node:events';
const bus = new EventEmitter();
bus.on('order', (id) => console.log('got order', id));
bus.emit('order', 42);
19. How do you read and write files with the `fs` module, and what is `fs.promises`?
The fs module offers synchronous, callback and promise-based APIs, and the promise version (fs/promises) is preferred in modern code because it works with async/await. Use readFile and writeFile for small files and streams for large ones. Avoid *Sync methods in servers because they block the event loop.
import { readFile, writeFile } from 'node:fs/promises';
await writeFile('note.txt', 'hello');
const text = await readFile('note.txt', 'utf8');
20. What is the `path` module and why use it instead of string concatenation?
The path module builds and parses file paths in a way that works across operating systems, handling separators (/ versus \) and normalization for you. Common helpers are path.join(), path.resolve(), path.basename(), path.extname() and path.dirname(). Use path.join(__dirname, 'file.txt') for paths relative to the current file in CommonJS.
21. How do you handle errors in Node.js?
Handle errors according to the style used: check the err argument in error-first callbacks, use .catch() on promises, and wrap await calls in try/catch. Synchronous throws also use try/catch, and EventEmitter errors need an 'error' listener. As a last resort, process.on('uncaughtException') and process.on('unhandledRejection') can log and then exit, because the process may be in an unsafe state. Distinguish operational errors, like a failed network call, from programmer bugs.
try {
const data = await readFile('missing.txt', 'utf8');
} catch (err) {
if (err.code === 'ENOENT') console.error('file not found');
else throw err;
}
22. How do you use environment variables in Node.js?
Environment variables are available on process.env as strings, and they are the standard way to keep configuration and secrets such as ports and API keys out of source code. You set them in the shell, in your deployment platform, or load a .env file. Since Node 20.6 you can run node --env-file=.env app.js without any package, while the dotenv package does the same on older versions. Never commit secrets to git.
const port = Number(process.env.PORT) || 3000;
console.log(`Listening on ${port}`);
23. What is the cluster module and why would you use it?
The cluster module lets you fork multiple Node.js worker processes that share the same server port so an application can use all CPU cores. A primary process spawns one worker per core, typically using os.availableParallelism(), and distributes incoming connections, round-robin on most platforms. Because workers are separate processes, they do not share memory, and each one has its own event loop. Tools like PM2 provide this behavior with less code.
import cluster from 'node:cluster';
import os from 'node:os';
import http from 'node:http';
if (cluster.isPrimary) {
for (let i = 0; i < os.availableParallelism(); i++) cluster.fork();
} else {
http.createServer((req, res) => res.end('ok')).listen(3000);
}
24. What are worker threads and how do they differ from the cluster module?
Worker threads (worker_threads) run JavaScript in separate threads inside the same process, each with its own V8 instance and event loop, and are meant for CPU-intensive tasks that would otherwise block the main thread. Unlike cluster, which creates separate processes for scaling servers, workers can share memory through SharedArrayBuffer and exchange messages cheaply with postMessage. They do not help with I/O-bound work, which Node already handles asynchronously.
25. How do you handle CPU-intensive tasks in Node.js without blocking the event loop?
Move the heavy work off the main thread, since a long synchronous loop freezes every request. Good options are worker threads, a worker pool such as Piscina, child processes, or a separate service or job queue. For some jobs you can also split the work into small chunks scheduled with setImmediate so I/O still gets a turn. Use built-in async crypto and zlib APIs, which already run on the libuv thread pool.
import { Worker } from 'node:worker_threads';
const worker = new Worker('./heavy.js', { workerData: { n: 45 } });
worker.on('message', (result) => console.log(result));
worker.on('error', console.error);
26. What is backpressure in Node.js streams and how do you handle it?
Backpressure is the signal a slow consumer sends to a fast producer so data is not buffered without limit in memory. writable.write() returns false when the internal buffer exceeds highWaterMark, and you should stop writing until the 'drain' event fires. The easiest way to get this right is pipe() or, better, stream.pipeline(), which handle backpressure and error cleanup for you.
function writeAll(ws, chunks) {
for (const c of chunks) {
if (!ws.write(c)) return ws.once('drain', () => writeAll(ws, chunks.slice(chunks.indexOf(c) + 1)));
}
ws.end();
}
27. Why is `pipeline()` preferred over `pipe()`?
stream.pipeline() forwards errors from any stream in the chain and destroys all of them when one fails, whereas pipe() does not propagate errors or clean up, which leaks file descriptors and memory. The promise version from node:stream/promises also works with async/await and resolves when the final stream finishes. Always attach error handling when you must use pipe() directly.
28. How would you read and process a 5 GB file without running out of memory?
Stream it instead of loading it with readFile, so only small chunks are in memory at a time. Use fs.createReadStream with readline to process line by line, or pipe through a Transform stream. Memory stays roughly constant regardless of file size, and backpressure keeps the pace matched to your processing.
import fs from 'node:fs';
import readline from 'node:readline';
const rl = readline.createInterface({ input: fs.createReadStream('huge.log') });
for await (const line of rl) {
if (line.includes('ERROR')) console.log(line);
}
29. What are the common causes of memory leaks in Node.js and how do you find them?
Typical causes are global variables or unbounded caches that only grow, event listeners that are never removed, timers and closures holding large objects, and unresolved promises that keep references alive. To find them, watch process.memoryUsage() over time, run with --inspect, and compare heap snapshots in Chrome DevTools to see which objects keep growing. Bound caches with an LRU policy and remove listeners you no longer need.
30. What does the warning "MaxListenersExceededWarning" mean?
It means more than 10 listeners were added for the same event on one EventEmitter, which Node flags as a possible memory leak. It is only a warning, but it usually points to code that registers a listener on every request or loop iteration without removing it. Fix the cause by calling off() or using once(), and only raise the limit with setMaxListeners() when many listeners are intentional.
31. What is the output of this code and why?
The output is 1, 4, 3, 2: synchronous code runs first, then the nextTick queue, then promise microtasks, then timers. console.log(1) and console.log(4) are synchronous, but here 4 is logged before the promise callback because the callback is queued as a microtask. The setTimeout callback waits for the timers phase of the event loop.
console.log(1);
setTimeout(() => console.log(2), 0);
Promise.resolve().then(() => console.log(3));
console.log(4);
32. What is wrong with using `async` inside `forEach`, and what should you do instead?
forEach ignores the promises returned by an async callback, so it does not wait for them and errors become unhandled rejections. Code after the loop runs before the work is done. Use a for...of loop with await for sequential work, or await Promise.all(items.map(...)) for parallel work.
items.forEach(async (i) => await save(i));
await Promise.all(items.map((i) => save(i)));
33. What is the difference between `Promise.all`, `allSettled`, `race` and `any`?
Promise.all resolves with all results but rejects as soon as one promise rejects, Promise.allSettled always waits for every promise and reports each outcome, Promise.race settles with the first promise to settle, and Promise.any resolves with the first fulfilled promise and rejects only if all fail. Use allSettled when partial failure is acceptable and race for timeouts.
34. How do you handle unhandled promise rejections in modern Node.js?
Since Node 15, an unhandled rejection crashes the process by default with a non-zero exit code. You should attach .catch() or use try/catch around every await, and add a process.on('unhandledRejection') handler that logs the reason and exits so a supervisor can restart the app. Do not silently swallow them, because the application state may be inconsistent.
35. How do you implement graceful shutdown in a Node.js server?
Listen for SIGTERM and SIGINT, stop accepting new connections with server.close(), wait for in-flight requests to finish, close database connections and then exit. Add a timeout that forces exit if cleanup hangs. This matters on Kubernetes and PM2, which send SIGTERM before killing the process, and it prevents dropped requests during deploys.
process.on('SIGTERM', () => {
server.close(async () => {
await db.end();
process.exit(0);
});
setTimeout(() => process.exit(1), 10000).unref();
});
36. What is middleware in Express and how does the order of middleware matter?
Middleware is a function (req, res, next) that runs between the request and the final handler and can modify the request, end the response or call next() to pass control on. Express runs middleware in the order it is registered, so body parsers, authentication and logging must be added before the routes that need them. Error-handling middleware has four arguments (err, req, res, next) and must be registered last.
37. How do you handle errors in async Express route handlers?
In Express 4, a rejected promise in an async handler is not passed to the error middleware, so you must catch it and call next(err) or use a wrapper. Express 5 forwards rejected promises automatically. A small wrapper keeps routes clean and guarantees the central error handler is used.
const wrap = (fn) => (req, res, next) => fn(req, res, next).catch(next);
app.get('/users/:id', wrap(async (req, res) => {
res.json(await User.findById(req.params.id));
}));
38. How would you secure a Node.js API?
Start with HTTPS, input validation, and authentication with short-lived tokens, then add layers. Use helmet for security headers, rate limiting against brute force, parameterized queries to prevent injection, restricted CORS, and password hashing with bcrypt or argon2. Keep secrets in environment variables, run npm audit regularly, and never trust data from req.body, req.query or headers.
39. What is CORS and how do you configure it in Node.js?
CORS (Cross-Origin Resource Sharing) is a browser mechanism where a server uses response headers to say which other origins may call it. Without the right headers the browser blocks the response, even though the server received the request. In Express use the cors package and list allowed origins explicitly instead of * when credentials are involved. Preflight OPTIONS requests must also be answered.
import cors from 'cors';
app.use(cors({ origin: 'https://app.example.com', credentials: true }));
40. How does JWT authentication work in a Node.js API and what are its pitfalls?
After login the server signs a JSON Web Token containing claims such as the user id and expiry, and the client sends it in the Authorization: Bearer header on each request, where the server verifies the signature. Pitfalls include storing long-lived tokens in localStorage where XSS can steal them, not setting expiry, accepting the none algorithm, and being unable to revoke a token before it expires. Use short access tokens with refresh tokens and a fixed algorithm list.
41. What is the difference between `spawn`, `exec`, `execFile` and `fork` in `child_process`?
spawn starts a command and streams its output, so it suits large output, while exec runs a command in a shell and buffers the whole output, which has a size limit. execFile runs an executable directly without a shell, which is safer. fork starts a new Node.js process with a built-in IPC channel for messaging. Never pass unsanitized user input to exec, because it enables command injection.
42. How would you scale a Node.js application horizontally?
Run multiple stateless instances behind a load balancer such as Nginx or a cloud load balancer, using cluster, PM2 or containers to use every core. Keep instances stateless by moving sessions and caches to Redis or a database, since memory is not shared between processes. Use health checks and graceful shutdown so instances can be added or removed without dropping traffic. Websockets additionally need sticky sessions or a shared pub/sub adapter.
43. How do you cache data in a Node.js application?
Cache expensive or frequently read results so you avoid repeating work. For a single process use an in-memory LRU cache with a size limit and TTL, and for multiple instances use a shared store such as Redis. Decide an invalidation strategy up front, either expiry or explicit deletion on writes, because stale data is the main risk. HTTP caching headers like ETag and Cache-Control help at the edge.
44. How do you profile and debug performance problems in Node.js?
Start by measuring rather than guessing. Run node --inspect and use Chrome DevTools, or node --prof and --cpu-prof, to capture CPU profiles and flame graphs, and use clinic.js or 0x for quick diagnosis. Track event loop delay with perf_hooks.monitorEventLoopDelay() to detect blocking code. Typical fixes are removing sync calls, adding indexes or caching, and moving CPU work to workers.
45. What is the `AbortController` and how is it used in Node.js?
AbortController is a standard API for cancelling asynchronous operations through its signal. Node APIs such as fetch, fs/promises, timers and events.once accept a signal option and reject with an AbortError when abort() is called. It is the clean way to implement request timeouts and cancellation without leaking work.
const ac = new AbortController();
setTimeout(() => ac.abort(), 3000);
try {
const res = await fetch('https://example.com/api', { signal: ac.signal });
} catch (err) {
if (err.name === 'AbortError') console.log('timed out');
}
46. What is the built-in test runner in Node.js, and how does it compare to Jest?
Since Node 18 the node:test module provides a built-in test runner with describe, it, mocking, hooks and coverage, run with node --test. It needs no dependencies and starts quickly, while Jest offers a richer ecosystem, snapshots and a mature watch mode. For small libraries and services the built-in runner with node:assert is often enough.
import test from 'node:test';
import assert from 'node:assert/strict';
test('adds numbers', () => {
assert.equal(1 + 2, 3);
});
47. What is the difference between `fetch` in Node.js and libraries like axios?
Node 18+ includes a global fetch based on the web standard, powered by undici, so you can make HTTP requests without installing anything. Unlike axios, fetch does not reject on 4xx or 5xx status codes and you must check res.ok yourself, and it has no built-in interceptors or automatic JSON parsing. Axios adds convenience such as interceptors and timeouts, but fetch is enough for most new code.
48. How do you prevent a callback or event handler from crashing the server when it throws?
A throw inside an asynchronous callback cannot be caught by an outer try/catch, because that call stack has already finished. Handle errors inside the callback itself, use promises with await, and register 'error' listeners on emitters and streams. Keep uncaughtException only as a last-resort logger before restarting the process via PM2 or a container orchestrator.
try {
setTimeout(() => { throw new Error('boom'); }, 0);
} catch (e) {
console.log('never reached');
}
49. What are `exports` conditions in `package.json` and why do they matter?
The exports field defines a package's public entry points and can map different files per condition such as import, require and types, so one package can ship both ESM and CommonJS builds. It also encapsulates the package: files not listed cannot be imported from outside. This is the modern replacement for main and is how dual packages are published safely.
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
50. How do you structure a large Node.js application for maintainability?
Separate concerns into layers, commonly routes or controllers for HTTP handling, services for business logic and repositories for data access, so logic can be tested without the web framework. Group code by feature rather than by file type, centralize configuration and error handling, and use dependency injection to keep modules replaceable. Add linting, TypeScript and automated tests to keep the codebase consistent as the team grows.