Node.js Interview Questions and Answers โ Ultimate Guide 2026
Every concept covered deeply โ event loop, streams, modules, Express, async, security, scaling & more. Simple English!
What is Node.js and why do developers use it?
Simple answer ๐ก Node.js is a JavaScript runtime built on Chrome's V8 engine. It lets you run JavaScript outside the browser โ on a server. Before Node.js, JavaScript only ran inside web browsers. Now you can use it to build backends, APIs, tools, and real-time apps.
Analogy ๐ Think of JavaScript as a train. The browser was the only track it could run on. Node.js built a whole new track โ now the same JavaScript train can run on servers, read files, connect to databases, and handle thousands of users at once.
What is non-blocking I/O and why is it important?
Simple answer ๐ก Non-blocking I/O means Node.js does NOT wait for slow operations (reading files, querying databases, calling APIs). It starts the operation, moves on to handle other things, and comes back when the result is ready.
Analogy ๐ฝ๏ธ A waiter takes orders from 10 tables, sends them all to the kitchen, and handles whoever's food is ready first. He never stands at one table waiting. Node.js is that waiter โ one thread serving thousands of requests at once.
// Blocking โ freezes while waiting:
const data = fs.readFileSync('bigfile.txt'); // waits here!
console.log('only runs AFTER file is done');
// Non-blocking โ moves on immediately:
fs.readFile('bigfile.txt', (err, data) => {
console.log('file is ready now!');
});
console.log('runs IMMEDIATELY while file loads');
What is the difference between Node.js and the browser?
Simple answer ๐ก Both run JavaScript with V8, but they have completely different APIs. The browser has the DOM, window, localStorage. Node.js has the file system, networking, OS access โ but no DOM.
Side by side ๐ Browser has: window, document, localStorage, fetch, DOM APIs โ all for UI.
Node.js has: global (not window), process, __dirname, require(), fs (file system), http, os, crypto โ all for servers.
Browser: sandboxed โ cannot touch your files or system. Node.js: full system access โ can read/write files, run commands, connect to databases.
What is the global object in Node.js?
Simple answer ๐ก In the browser, everything global lives on window. In Node.js, it lives on global. Variables added to global are accessible everywhere in your app โ but avoid doing this, it creates messy code.
// Important Node.js globals you use constantly:
__dirname // full path to the current file's folder
__filename // full path to the current file
process // info about the running Node.js process
require() // function to import other modules
module // info about the current module
exports // what this module shares with others
// NOTE: __dirname and __filename do NOT exist in ES modules!
// In ESM use: new URL('.', import.meta.url).pathname
What is the process object in Node.js?
Simple answer ๐ก process is a global object that gives you information and control over the current Node.js process โ environment variables, command-line arguments, memory, and the ability to exit cleanly.
process.env.NODE_ENV // 'production' or 'development'
process.env.PORT // your custom env variables
process.argv // command-line args array
process.cwd() // current working directory
process.pid // process ID (a number)
process.version // Node.js version: 'v20.5.0'
process.platform // 'linux', 'darwin', 'win32'
process.memoryUsage() // { heapUsed, heapTotal, rss, external }
process.uptime() // seconds since app started
// Exit the process:
process.exit(0) // 0 = success
process.exit(1) // 1 = error
// Catch fatal errors โ prevents crash:
process.on('uncaughtException', (err) => {
console.error('Fatal error:', err);
process.exit(1); // must exit after this!
});
process.on('unhandledRejection', (reason) => {
console.error('Unhandled promise rejection:', reason);
});
What is single-threaded in Node.js and is it a problem?
Simple answer ๐ก Node.js runs JavaScript on a single thread โ only one piece of code runs at a time. This is NOT a problem for I/O (files, network, databases) because Node offloads those to the OS. It IS a problem for CPU-heavy work like image processing or math.
Why single-thread is OK for most apps ๐ 90% of web server work is waiting โ waiting for the database, waiting for the API, waiting for the file. Node.js hands all that waiting off to the OS and serves other requests in the meantime.
The only problem: if you do heavy math on the main thread for 2 seconds, ALL other requests are frozen for 2 seconds. Solutions: Worker Threads, Child Processes, or Cluster module.
What is V8 and what role does it play in Node.js?
Simple answer ๐ก V8 is Google's JavaScript engine โ the same one that runs JavaScript in Chrome. Node.js uses V8 to run your JavaScript code. V8 compiles JavaScript directly to machine code (JIT compilation), which makes Node.js fast.
How it all fits together ๐งฉ V8: runs your JavaScript, compiles it to machine code.
libuv: provides the event loop, thread pool, and async I/O (file, network).
Node.js bindings: connects V8 + libuv and adds built-in modules (fs, http, etc.).
When you write fs.readFile(), Node.js tells libuv to read the file (in a background thread), and V8 keeps running your other JavaScript while it waits.
What is libuv and what does it do?
Simple answer ๐ก libuv is a C library that powers Node.js's async I/O and event loop. It provides a thread pool for operations that can't be done async at the OS level โ like file system, DNS lookups, and crypto.
What libuv manages ๐ The Event Loop โ the main loop that picks up callbacks and runs them.
The Thread Pool โ 4 threads by default for file I/O, crypto, DNS.
OS-level async APIs โ epoll (Linux), kqueue (macOS), IOCP (Windows).
When you call fs.readFile(): libuv hands it to a thread pool thread. That thread blocks (it reads from disk). When done, libuv pushes the callback into the event loop queue. Your JS thread picks it up and runs the callback.
What is the Event Loop in Node.js?
Simple answer ๐ก The Event Loop is the heart of Node.js. It continuously checks: "Is there any work to do?" It picks up completed async operations (file reads, network responses, timers) and runs their callbacks on the single JavaScript thread.
How it works step by step ๐ 1. Your code runs synchronously (the call stack).
Async operations start (setTimeout, fs.readFile, fetch) โ handed off to OS/thread pool.
Node.js is free to run other code while waiting.
When an async operation completes, its callback is put in a queue.
The Event Loop checks โ is the call stack empty? If yes, pick a callback from the queue and run it.
Repeat forever until there's nothing left to do.
Why this enables handling thousands of connections ๐ก A traditional server creates a new thread per connection (expensive โ threads use ~1MB RAM each). Node.js handles all connections on one thread via the event loop. 10,000 connections = 10,000 callbacks waiting in a queue โ no 10,000 threads needed!
What are the phases of the Event Loop?
Simple answer ๐ก The event loop runs in 6 phases, each with its own queue. It processes all callbacks in one phase before moving to the next. Between each phase, it drains the microtask queue (Promises + nextTick).
The 6 phases in order ๐ 1. timers: runs setTimeout and setInterval callbacks whose time has expired.
pending callbacks: I/O callbacks deferred from the previous loop iteration.
idle, prepare: internal Node.js use only โ you can ignore this.
poll: retrieves new I/O events and runs their callbacks. If nothing to do, waits here for new events.
check: runs setImmediate() callbacks.
close callbacks: runs cleanup callbacks like socket.on('close').
Between every phase: Node.js runs the microtask queue โ all process.nextTick() callbacks first, then all Promise .then() callbacks. These have the highest priority!
What is the exact execution order: sync โ nextTick โ Promise โ setTimeout โ setImmediate?
Simple answer ๐ก The order is: synchronous code โ process.nextTick โ Promises (.then) โ setTimeout/setInterval โ setImmediate.
console.log('1 - sync start');
setTimeout(() => console.log('5 - setTimeout'), 0);
setImmediate(() => console.log('6 - setImmediate'));
Promise.resolve().then(() => console.log('3 - Promise'));
process.nextTick(() => console.log('2 - nextTick'));
console.log('4 - sync end');
// Output: 1, 4, 2, 3, 5, 6
// (note: sync first, then nextTick, then Promise,
// then macrotasks: setTimeout then setImmediate)
Why this order? ๐ค Sync code runs first โ it's on the call stack, nothing else can run.
process.nextTick runs before Promises โ it's a special "micro-microtask".
Promises run โ they're microtasks, cleared before returning to the event loop.
setTimeout(0) โ macrotask, runs in the timers phase.
setImmediate โ runs in the check phase (after poll), often after setTimeout.
What is process.nextTick() and when should you use it?
Simple answer ๐ก process.nextTick() schedules a callback to run at the end of the current operation, before the event loop continues โ even before Promises. It's the highest priority async callback in Node.js.
console.log('start');
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('Promise'));
console.log('end');
// Output: start โ end โ nextTick โ Promise
// nextTick runs before Promise!
When to use it ๐ โ Emit events after a constructor returns (so listeners can be attached first).
โ Throw errors asynchronously after setting up something synchronously.
โ Ensure a callback runs in a consistent async manner.
โ ๏ธ Warning: Don't use it recursively โ if nextTick keeps scheduling new nextTick calls, the event loop starves. I/O callbacks never run and your server freezes!
What is the difference between process.nextTick() and setImmediate()?
Simple answer ๐ก Both schedule a callback โ but at different points. process.nextTick runs before the event loop continues (highest priority). setImmediate runs in the check phase of the NEXT event loop iteration (after I/O callbacks).
Which one to use? ๐ Use process.nextTick: when you need something to run before any I/O. Rare โ usually only for library internals or error handling patterns.
Use setImmediate: when you want to break up CPU work or defer something to after I/O. Safer โ it doesn't starve I/O the way nextTick can.
The Node.js team recommends setImmediate over process.nextTick in most cases because it's less likely to cause issues.
What is the Thread Pool in Node.js?
Simple answer ๐ก Node.js has a background thread pool (managed by libuv) with 4 threads by default. These threads handle operations that can't be done async at the OS level โ like file I/O, DNS lookups, crypto, and zlib compression.
What uses the thread pool ๐ fs.readFile, fs.writeFile โ file system operations.
crypto.pbkdf2, crypto.scrypt, crypto.randomBytes โ crypto hashing.
dns.lookup (but NOT dns.resolve โ that's async!).
zlib โ compression and decompression.
Performance gotcha: If 4 bcrypt password hashes run at once, the 5th must wait. Your server has 8 CPU cores doing nothing while 1 thread pool thread is busy. Fix: increase UV_THREADPOOL_SIZE=8 environment variable.
What is the CommonJS module system (require/module.exports)?
Simple answer ๐ก CommonJS is Node.js's built-in module system. Use require() to import code from other files. Use module.exports to share code from a file. This is the default in Node.js โ no setup needed.
// math.js โ exporting:
function add(a, b) { return a + b; }
const PI = 3.14159;
module.exports = { add, PI };
// app.js โ importing:
const { add, PI } = require('./math'); // your own file
const fs = require('fs'); // built-in module
const express = require('express'); // npm package
// How require resolves files:
// './math' โ looks for ./math.js, ./math/index.js
// 'express' โ looks in node_modules/express
// 'fs', 'http' โ built-in Node.js modules
Module caching โก Node.js caches every module after the first require(). So requiring the same module 10 times only runs its code once โ the same object is returned every time. This makes modules act like singletons โ perfect for database connections!
What is the difference between module.exports and exports?
Simple answer ๐ก exports is a shortcut reference that initially points to the same object as module.exports. If you reassign exports, it breaks the connection โ module.exports is what actually gets exported.
// Adding to the object โ both work the same:
exports.add = (a, b) => a + b; // โ
module.exports.add = (a, b) => a + b; // โ
same thing
// Reassigning โ only module.exports works:
module.exports = { add, PI }; // โ
works
exports = { add, PI }; // โ breaks the link!
// exports now points to a NEW object
// module.exports is still {} โ nothing gets exported!
Simple rule ๐ Adding properties โ use exports.thing = value (shortcut is fine).
Replacing entirely โ use module.exports = value.
When in doubt โ always use module.exports. It always works.
What are ES Modules (import/export) in Node.js?
Simple answer ๐ก ES Modules are the modern JavaScript standard โ using import/export syntax. Node.js supports them from v12+. They're the same syntax used in the browser. Enable them with .mjs extension or "type": "module" in package.json.
// math.mjs โ named exports:
export const add = (a, b) => a + b;
export const PI = 3.14159;
// default export (one per file):
export default function multiply(a, b) { return a * b; }
// importing:
import multiply, { add, PI } from './math.js';
import * as MathUtils from './math.js'; // import all as object
import { add as sum } from './math.js'; // rename on import
CJS vs ESM key differences ๐ __dirname / __filename: available in CJS, NOT in ESM (use import.meta.url).
require(): CJS only. ESM uses import() (dynamic import).
Top-level await: ESM only โ you can await at the top level without async!
Loading: CJS = synchronous. ESM = asynchronous (allows static analysis, tree-shaking).
Exports: CJS exports a copy. ESM exports live bindings (the importer sees changes).
What are the built-in (core) modules in Node.js?
Simple answer ๐ก Node.js ships with built-in modules you can use without installing anything from npm. They cover file system, networking, crypto, path handling, and more.
The most important core modules ๐ fs / fs/promises โ read, write, delete files and folders.
path โ join and resolve file paths cross-platform.
http / https โ create HTTP servers, make HTTP requests.
events โ EventEmitter class (base for most of Node.js).
stream โ handle streaming data (Readable, Writable, Transform).
crypto โ hashing, encryption, random values.
os โ CPU count, memory, hostname, platform.
child_process โ run shell commands, other programs.
worker_threads โ true multi-threading.
cluster โ run multiple processes on multiple CPU cores.
url โ parse and format URLs.
util โ promisify, inspect, and other helpers.
What are streams in Node.js?
Simple answer ๐ก Streams let you process data piece by piece (in chunks) instead of loading all of it into memory at once. This is crucial for large files, video, or real-time data โ you process data as it arrives.
Analogy ๐ฟ Watching Netflix โ you don't download the entire 4GB movie before watching. It streams to you chunk by chunk as you watch. Node.js streams work the same: process a 10GB log file chunk by chunk instead of loading 10GB into RAM at once.
The four types of streams ๐ Readable: source you read FROM. Example: reading a file, incoming HTTP request body.
Writable: destination you write TO. Example: writing a file, HTTP response.
Duplex: both readable and writable. Example: TCP socket โ sends and receives.
Transform: duplex that transforms data passing through. Example: gzip compression, encryption.
const fs = require('fs');
// Without streams โ loads entire 2GB file into RAM!
const data = fs.readFileSync('2gb-log.txt'); // โ crashes!
// With streams โ uses fixed small amount of memory:
const readStream = fs.createReadStream('2gb-log.txt');
const writeStream = fs.createWriteStream('output.txt');
readStream.pipe(writeStream); // โ
handles any file size!
What is pipe() in streams and how does it work?
Simple answer ๐ก pipe() connects a Readable stream to a Writable stream โ data flows automatically from one to the other. It also handles backpressure for you: if the writer is slow, it pauses the reader so memory doesn't overflow.
const fs = require('fs');
const zlib = require('zlib');
// Chain multiple pipes โ compress a file:
fs.createReadStream('large-file.txt') // read file
.pipe(zlib.createGzip()) // compress it
.pipe(fs.createWriteStream('large-file.txt.gz')); // write compressed
// Handle errors in pipe chain:
const { pipeline } = require('stream/promises');
await pipeline(
fs.createReadStream('input.txt'),
zlib.createGzip(),
fs.createWriteStream('output.gz')
);
// pipeline() is better than pipe() โ handles errors properly!
Use pipeline() over pipe() ๐ pipe() doesn't forward errors โ if the source errors, the destination keeps open and leaks. pipeline() from stream/promises properly closes all streams when any one errors. Always prefer pipeline() in production code.
What is backpressure in streams and why does it matter?
Simple answer ๐ก Backpressure happens when data is being produced faster than it can be consumed โ the buffer fills up and memory spikes. Streams handle this by signaling "slow down" to the producer. pipe() does this automatically.
Analogy ๐ฐ A fire hose filling a bathtub faster than the drain can empty it โ eventually the tub overflows (memory crash). Backpressure is the drain saying "slow down the hose." pipe() handles this automatically: when the write buffer is full, it pauses reading.
// Manual backpressure โ what pipe() does internally:
readStream.on('data', (chunk) => {
const canContinue = writeStream.write(chunk);
if (!canContinue) {
readStream.pause(); // stop reading โ write buffer full!
}
});
writeStream.on('drain', () => {
readStream.resume(); // write buffer cleared โ keep reading!
});
// Just use pipe() โ it handles all of this for you:
readStream.pipe(writeStream);
How do you create a custom Transform stream?
Simple answer ๐ก Extend the Transform class and implement _transform(chunk, encoding, callback). This lets you process data as it flows through โ like filtering lines, converting formats, or encrypting on the fly.
const { Transform } = require('stream');
// Custom transform: uppercase every line:
class UpperCaseTransform extends Transform {
_transform(chunk, encoding, callback) {
// chunk is a Buffer โ convert to string, process, push result:
const upperCased = chunk.toString().toUpperCase();
this.push(upperCased); // send to the next stream
callback(); // signal we're done with this chunk
}
}
// Use it in a pipeline:
const { pipeline } = require('stream/promises');
await pipeline(
fs.createReadStream('input.txt'),
new UpperCaseTransform(), // transform in the middle
fs.createWriteStream('output.txt')
);
What is EventEmitter in Node.js?
Simple answer ๐ก EventEmitter is the core pub/sub system in Node.js. Objects can emit named events. Other code can listen to those events. Almost everything in Node.js (streams, HTTP server, process) is built on EventEmitter.
const EventEmitter = require('events');
class OrderService extends EventEmitter {
async placeOrder(item) {
const order = await saveToDatabase(item);
this.emit('orderPlaced', order); // fire the event!
return order;
}
}
const service = new OrderService();
// Subscribe โ multiple listeners can listen to same event:
service.on('orderPlaced', (order) => sendConfirmationEmail(order));
service.on('orderPlaced', (order) => updateInventory(order));
service.on('orderPlaced', (order) => notifyWarehouse(order));
// Run only once then auto-removes:
service.once('orderPlaced', sendWelcomeBonus);
// Unsubscribe:
service.off('orderPlaced', handler); // modern (Node 10+)
service.removeListener('orderPlaced', handler); // older way
await service.placeOrder('Pizza'); // all 3 listeners fire!
What is the 'error' event and why is it special?
Simple answer ๐ก The 'error' event is special in Node.js. If an EventEmitter emits 'error' and nobody is listening โ Node.js throws the error and crashes the process. Always add an error listener on EventEmitters!
const emitter = new EventEmitter();
// DANGEROUS โ no error listener:
emitter.emit('error', new Error('boom'));
// โ Uncaught Error: process crashes!
// SAFE โ always handle errors:
emitter.on('error', (err) => {
console.error('Handled:', err.message); // logs, doesn't crash!
});
emitter.emit('error', new Error('boom')); // โ
handled
// This is why you always see:
server.on('error', handler);
stream.on('error', handler);
socket.on('error', handler);
Common gotcha โ ๏ธ Streams are EventEmitters too. If you forget .on('error', handler) on a stream and a read fails (file not found, network drop), Node.js crashes. With pipeline() this is handled automatically โ one more reason to prefer pipeline over manual piping.
What is the maximum number of listeners and memory leak warning?
Simple answer ๐ก By default, EventEmitter warns if more than 10 listeners are added to the same event. This is a memory leak detection feature โ you probably forgot to remove old listeners in a loop.
const emitter = new EventEmitter();
// Node.js warns after 10 listeners:
for (let i = 0; i < 15; i++) {
emitter.on('data', handler); // after 10: MaxListenersExceededWarning!
}
// Fix 1 โ increase the limit if intentional:
emitter.setMaxListeners(20);
EventEmitter.defaultMaxListeners = 20; // for all emitters
// Fix 2 โ remove listeners when done:
function handleData(data) { /* ... */ }
emitter.on('data', handleData);
// Later when done:
emitter.off('data', handleData); // removes THIS specific listener
// Check listener count:
emitter.listenerCount('data'); // number of listeners for 'data'
emitter.eventNames(); // ['data', 'error', ...]
What are callbacks and what is callback hell?
Simple answer ๐ก A callback is a function you pass to another function, to be called when an async operation is done. Callback hell is when you nest callbacks inside callbacks inside callbacks โ creating a hard-to-read "pyramid of doom."
// Callback hell โ nesting makes this unreadable:
getUser(id, function(err, user) {
if (err) return handleError(err);
getPosts(user.id, function(err, posts) {
if (err) return handleError(err);
getComments(posts[0].id, function(err, comments) {
if (err) return handleError(err);
// 4 levels deep, 3 error handlers, total mess!
});
});
});
Problems with callbacks ๐ โ Deep nesting โ hard to read and maintain.
โ Error handling on every callback โ repeated code.
โ Can't use try/catch โ errors don't propagate.
โ Hard to run things in parallel.
โ Solution: Promises and async/await.
What is util.promisify() and when do you use it?
Simple answer ๐ก util.promisify() converts an old-style callback function into a Promise-returning function, so you can use it with async/await. Works on any function that follows the Node.js callback convention: (err, result) as the last argument.
const util = require('util');
const fs = require('fs');
// Old callback style โ can't use await:
fs.readFile('file.txt', 'utf8', (err, data) => {
if (err) throw err;
console.log(data);
});
// Convert to Promise:
const readFile = util.promisify(fs.readFile);
// Now use async/await:
const data = await readFile('file.txt', 'utf8');
console.log(data);
// Tip: most Node.js APIs now have a promises version built in:
const { readFile } = require('fs/promises'); // CJS
import { readFile } from 'node:fs/promises'; // ESM
// So you often don't even need util.promisify anymore!
What is the difference between sequential and parallel async operations?
Simple answer ๐ก Sequential: operations run one after another โ next starts only after previous finishes. Parallel: all operations start at the same time โ you wait for all to complete. Parallel is much faster when operations don't depend on each other.
// Sequential โ slow! Each waits for the previous (600ms total):
async function sequential() {
const user = await fetchUser(1); // 200ms
const posts = await fetchPosts(2); // 200ms
const tags = await fetchTags(3); // 200ms
}
// Parallel โ fast! All start at the same time (200ms total):
async function parallel() {
const [user, posts, tags] = await Promise.all([
fetchUser(1), // starts immediately
fetchPosts(2), // starts immediately
fetchTags(3), // starts immediately
]);
}
// Parallel with concurrency limit:
// Don't hammer an API with 1000 requests at once!
async function limitedParallel(ids) {
const chunks = [];
for (let i = 0; i < ids.length; i += 5) {
chunks.push(ids.slice(i, i + 5));
}
const results = [];
for (const chunk of chunks) {
const chunkResults = await Promise.all(chunk.map(fetchItem));
results.push(...chunkResults);
}
return results;
}
How do you handle errors properly with async/await in Node.js?
Simple answer ๐ก Wrap await calls in try/catch. Async errors behave like synchronous errors inside async functions โ catch handles both. The most common mistake is forgetting try/catch and getting unhandled Promise rejections.
// โ
Correct โ try/catch catches async errors:
async function getUser(id) {
try {
const user = await User.findById(id);
return user;
} catch (err) {
throw new Error(`Failed to get user: ${err.message}`);
}
}
// โ Common mistake โ try/catch doesn't catch async:
function badHandler(req, res) {
try {
fetchData().then(data => res.json(data)); // .then is async!
// if fetchData rejects, try/catch MISSES it!
} catch (err) {} // never runs for Promise errors!
}
// โ
Fix โ await the Promise:
async function goodHandler(req, res, next) {
try {
const data = await fetchData(); // await makes it catchable!
res.json(data);
} catch (err) {
next(err); // pass to Express error handler
}
}
// Useful pattern โ wrap to avoid repetitive try/catch:
const safe = async (promise) => {
try { return [null, await promise]; }
catch (e) { return [e, null]; }
};
const [err, data] = await safe(fetchData());
if (err) return handleError(err);
What is the race condition problem in async code and how do you fix it?
Simple answer ๐ก A race condition happens when multiple async operations are in flight and responses arrive out of order โ the UI or data can show stale/wrong results. The most common example: typing in a search box, older results arriving after newer ones.
// RACE CONDITION โ older query result overwrites newer:
let currentResults = null;
async function search(query) {
const results = await fetchResults(query); // async!
currentResults = results; // what if an older fetch finishes LAST?
}
// user types: 're' then 'react'
// Both fetches start. 'react' resolves first (maybe cached).
// Then 're' resolves and OVERWRITES with wrong data!
// FIX 1 โ cancel old requests with AbortController:
let currentController = null;
async function search(query) {
if (currentController) currentController.abort(); // cancel previous
currentController = new AbortController();
try {
const results = await fetchResults(query, currentController.signal);
currentResults = results;
} catch (err) {
if (err.name === 'AbortError') return; // expected โ ignore
throw err;
}
}
// FIX 2 โ ignore stale responses with a sequence counter:
let requestId = 0;
async function search(query) {
const id = ++requestId;
const results = await fetchResults(query);
if (id !== requestId) return; // a newer request was made โ ignore this
currentResults = results;
}
How does Express.js middleware work?
Simple answer ๐ก Middleware is a function with three parameters โ (req, res, next). It runs between the request arriving and the response being sent. Each middleware can modify the request, send a response, or call next() to pass control to the next middleware.
// Middleware signature:
function logger(req, res, next) {
console.log(`\({req.method} \){req.url}`);
next(); // MUST call next() or the request hangs forever!
}
function auth(req, res, next) {
const token = req.headers.authorization;
if (!token) return res.status(401).json({ error: 'No token' });
req.user = verifyToken(token); // attach data to request
next();
}
// Apply globally โ runs for every request:
app.use(express.json()); // parse JSON bodies
app.use(logger);
// Apply to specific routes only:
app.get('/profile', auth, (req, res) => {
res.json(req.user); // auth middleware added this!
});
// Error-handling middleware โ MUST have 4 parameters:
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({ error: err.message });
});
Middleware order matters! ๐ Express runs middleware in the order you call app.use(). Error handlers MUST go last. Auth middleware must be placed BEFORE the routes it protects. If you define a route before adding body parsing middleware, the body won't be available.
What is the exact order Express executes middleware and routes?
Simple answer ๐ก Express runs code in the exact order it's registered. Every call to app.use(), app.get(), etc. adds to the stack. The request travels through them one by one until a response is sent or error handler is reached.
// Request travels top to bottom:
app.use(express.json()); // 1. parse body
app.use(cors()); // 2. CORS headers
app.use(logger); // 3. log the request
app.use(authenticate); // 4. verify JWT token
app.get('/users', handler); // 5. handle GET /users
app.post('/users', handler);// 5. handle POST /users
// Error handler โ LAST, after all routes:
app.use((err, req, res, next) => res.status(500).json({ err }));
// If no route matches โ 404 handler (also last):
app.use((req, res) => res.status(404).json({ error: 'Not Found' }));
// Chain stops when:
// - res.send/json/end is called (response sent)
// - next(err) is called (jumps to error handler)
// - next() is never called (request hangs!)
How do you handle errors globally in an Express app?
Simple answer ๐ก Express has a special error-handling middleware with 4 parameters (err, req, res, next). Any call to next(err) or an unhandled error in async code jumps straight to this handler. Place it LAST after all routes.
// Async route wrapper โ avoids try/catch in every route:
const asyncHandler = fn => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
// Clean routes โ no try/catch needed:
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
const err = new Error('User not found');
err.status = 404;
throw err; // asyncHandler catches and calls next(err)
}
res.json(user);
}));
// Custom error class for structured errors:
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.status = statusCode;
this.isOperational = true; // mark as handled error
}
}
// Global error handler โ MUST be last:
app.use((err, req, res, next) => {
const status = err.status || 500;
// Don't leak stack traces to production:
const message = process.env.NODE_ENV === 'production' && status === 500
? 'Internal Server Error'
: err.message;
console.error(err); // log full error with stack
res.status(status).json({ error: message });
});
What is the difference between app.use() and app.get()/post()/put()/delete()?
Simple answer ๐ก app.use() matches any HTTP method and any path starting with the prefix. app.get/post/put/delete() match only that specific method and an exact path.
app.use('/api', router); // matches ALL methods: GET /api, POST /api/users...
app.use(logger); // matches everything (no path = all paths)
app.get('/users', handler); // only GET /users exactly
app.post('/users', handler); // only POST /users exactly
// Router โ a mini Express app for grouping routes:
const router = express.Router();
router.get('/', getAll); // GET /api/users (when mounted at /api/users)
router.post('/', create); // POST /api/users
router.get('/:id', getOne); // GET /api/users/42
router.put('/:id', update); // PUT /api/users/42
router.delete('/:id', remove);// DELETE /api/users/42
app.use('/api/users', router);
What is the Buffer class and when do you use it?
Simple answer ๐ก A Buffer is a fixed-size chunk of raw binary data (bytes). Node.js uses it to handle binary data โ like image files, network packets, or encrypted data โ before converting to strings.
// Create buffers:
const buf1 = Buffer.from('Hello', 'utf8'); // from string
const buf2 = Buffer.from([0x48, 0x65, 0x6c]); // from byte array
const buf3 = Buffer.alloc(10); // 10 zero bytes
const buf4 = Buffer.allocUnsafe(10); // 10 uninitialized bytes (faster)
// Convert buffer to string:
buf1.toString('utf8'); // "Hello"
buf1.toString('hex'); // "48656c6c6f"
buf1.toString('base64'); // "SGVsbG8="
// Info:
buf1.length; // 5 bytes
buf1[0]; // 72 (ASCII code of 'H')
// Buffers are what you get from streams by default:
fs.createReadStream('photo.jpg').on('data', (chunk) => {
// chunk is a Buffer of raw bytes!
console.log(chunk instanceof Buffer); // true
});
What is the path module and why do you always need it?
Simple answer ๐ก The path module builds file paths correctly for every operating system. Windows uses backslashes (), Mac/Linux use forward slashes (/). path handles this automatically โ never manually concatenate paths with string concatenation!
const path = require('path');
// ALWAYS use path.join โ handles OS differences:
path.join(__dirname, 'public', 'images', 'logo.png');
// "/home/user/app/public/images/logo.png" (Linux)
// "C:\Users\user\app\public\images\logo.png" (Windows)
// โ Never do this โ breaks on Windows!
__dirname + '/public/' + 'logo.png'; // breaks!
// Resolve โ creates absolute path from cwd:
path.resolve('uploads', 'photo.jpg');
// "/home/user/app/uploads/photo.jpg"
// Parse parts:
path.basename('/home/user/photo.jpg'); // "photo.jpg"
path.dirname('/home/user/photo.jpg'); // "/home/user"
path.extname('/home/user/photo.jpg'); // ".jpg"
path.parse('/home/user/photo.jpg');
// { root:'/', dir:'/home/user', base:'photo.jpg', ext:'.jpg', name:'photo' }
What is the Cluster module and how does it use multiple CPU cores?
Simple answer ๐ก The Cluster module lets you run multiple Node.js processes โ one per CPU core. A master process manages worker processes. Each worker runs your full app and handles requests independently. This multiplies throughput linearly with core count.
const cluster = require('cluster');
const os = require('os');
if (cluster.isPrimary) {
const numCPUs = os.cpus().length;
console.log(`Master \({process.pid} โ spawning \){numCPUs} workers`);
// One worker per CPU core:
for (let i = 0; i {
console.log(`Worker ${worker.pid} died โ restarting`);
cluster.fork();
});
} else {
// Each worker is a fully independent Express app:
const app = require('./app');
app.listen(3000);
console.log(`Worker ${process.pid} started`);
}
In production โ use PM2 instead ๐ญ pm2 start app.js -i max โ starts one worker per CPU automatically, with auto-restart, logging, zero-downtime reload. PM2 is much easier than managing raw Cluster code. Use Cluster only if you need custom logic between master and workers.
What are Worker Threads and how are they different from Cluster?
Simple answer ๐ก Worker Threads run JavaScript in true separate threads within the same process โ perfect for CPU-intensive tasks that would block the main thread. Cluster creates separate processes.
Cluster vs Worker Threads ๐ Cluster: Multiple processes. Separate memory. For scaling HTTP servers โ each process handles requests independently. No shared memory.
Worker Threads: Multiple threads. Can share memory (SharedArrayBuffer). For CPU-heavy work within one process โ image processing, crypto, data analysis.
// main.js โ offload heavy work to a thread:
const { Worker } = require('worker_threads');
app.get('/calculate', async (req, res) => {
const result = await new Promise((resolve, reject) => {
const worker = new Worker('./heavy.js', {
workerData: { input: req.query.n }
});
worker.on('message', resolve);
worker.on('error', reject);
});
res.json({ result }); // main thread stayed responsive!
});
// heavy.js โ runs in a separate thread:
const { workerData, parentPort } = require('worker_threads');
let sum = 0;
for (let i = 0; i < workerData.input; i++) sum += i; // heavy CPU work!
parentPort.postMessage(sum); // send back to main thread
What causes memory leaks in Node.js and how do you prevent them?
Simple answer ๐ก A memory leak is when objects that are no longer needed stay in memory โ because something still holds a reference to them. Over time, the process uses more RAM until it slows or crashes.
Top 5 causes of memory leaks in Node.js ๐ 1. Event listeners not removed: Adding listeners in a loop without removing. Fix: always emitter.off(event, handler) when done.
Timers not cleared: setInterval keeps running after its component is gone. Fix: clearInterval(id) in cleanup.
Growing in-memory caches: unbounded Map or Array that grows forever. Fix: use size limits + eviction, or WeakMap.
Closures holding large data: function captures a huge object it no longer needs. Fix: null out large references when done.
Global variables accumulating data: accidental global variables. Fix: strict mode, proper scoping.
// Detect leaks:
console.log(process.memoryUsage());
// { heapUsed: 12MB, heapTotal: 20MB, rss: 45MB, external: 1MB }
// Monitor over time โ if heapUsed keeps growing โ leak!
// Bounded cache โ prevents unbounded growth:
const cache = new Map();
const MAX = 1000;
function addToCache(key, value) {
if (cache.size >= MAX) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey); // evict oldest
}
cache.set(key, value);
}
What is graceful shutdown and why is it critical?
Simple answer ๐ก Graceful shutdown means stopping the server cleanly โ finishing all in-progress requests, closing database connections, and releasing resources โ before the process exits. Without it, active requests get dropped and data can be corrupted.
const server = app.listen(3000);
async function shutdown(signal) {
console.log(`${signal} received โ shutting down...`);
// Step 1: Stop accepting new requests:
server.close(async () => {
console.log('HTTP server closed');
// Step 2: Close database connections:
await mongoose.connection.close();
await pgPool.end();
// Step 3: Close other connections:
await redisClient.quit();
await bullWorker.close();
console.log('All connections closed');
process.exit(0);
});
// Step 4: Force exit if shutdown takes too long:
setTimeout(() => {
console.error('Forcing exit after 30s timeout');
process.exit(1);
}, 30_000);
}
// Listen for shutdown signals:
process.on('SIGTERM', () => shutdown('SIGTERM')); // Docker/k8s stop
process.on('SIGINT', () => shutdown('SIGINT')); // Ctrl+C
Why this matters in production ๐ญ Kubernetes/Docker sends SIGTERM before force-killing a process. Without graceful shutdown: ongoing database transactions are aborted, active HTTP requests get connection reset errors, file writes are incomplete. With graceful shutdown: users get their responses, data stays consistent.
How does JWT authentication work in Node.js?
Simple answer ๐ก A JWT (JSON Web Token) is a signed token containing user info. After login, the server creates a JWT and sends it to the client. The client sends it back with every request. The server verifies the signature โ no database lookup needed.
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET; // keep this secret!
// LOGIN โ create the token:
app.post('/login', async (req, res) => {
const user = await User.findOne({ email: req.body.email });
const ok = await bcrypt.compare(req.body.password, user.password);
if (!ok) return res.status(401).json({ error: 'Wrong credentials' });
const token = jwt.sign(
{ userId: user._id, email: user.email, role: user.role },
SECRET,
{ expiresIn: '7d' }
);
res.json({ token });
});
// AUTH MIDDLEWARE โ verify on every protected request:
function authenticate(req, res, next) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'No token' });
try {
req.user = jwt.verify(token, SECRET); // throws if invalid/expired!
next();
} catch (err) {
res.status(401).json({ error: 'Invalid or expired token' });
}
}
// PROTECTED ROUTE:
app.get('/profile', authenticate, (req, res) => {
res.json({ user: req.user }); // jwt.verify decoded payload
});
How do you hash passwords correctly in Node.js?
Simple answer ๐ก Use bcrypt. Never store plain text passwords. bcrypt hashes with salt (prevents rainbow tables) and is intentionally slow (prevents brute force). Always hash on register, compare on login.
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12; // 10-12 is standard โ higher = slower
// REGISTER โ hash before saving:
async function register(email, plainPassword) {
const hash = await bcrypt.hash(plainPassword, SALT_ROUNDS);
// "\(2b\)12$..." โ this is what goes in the database
await User.create({ email, password: hash });
}
// LOGIN โ compare, never dehash:
async function login(email, plainPassword) {
const user = await User.findOne({ email });
if (!user) throw new Error('User not found');
const match = await bcrypt.compare(plainPassword, user.password);
if (!match) throw new Error('Wrong password');
return user;
}
Why bcrypt and not SHA256? ๐ค SHA256 is designed to be fast (hashes billions per second). An attacker can try every word in a dictionary instantly. bcrypt is deliberately slow โ 100ms per hash. Even with GPUs, brute force takes centuries. Cost factor 12 means: 2^12 = 4096 iterations. If hardware gets faster, increase the rounds!
What are the most important security practices for Node.js?
Simple answer ๐ก Security in Node.js is about layering defenses โ input validation, proper headers, rate limiting, secrets management, and keeping dependencies updated.
Top 8 security practices ๐ 1. Validate all input: Use Joi or Zod. Never trust client data.
Helmet.js: Sets 15+ security HTTP headers in one line.
Rate limiting: 5 login attempts per 15 min. Use express-rate-limit.
HTTPS only: Never serve over plain HTTP in production.
Parameterized queries: Never build SQL/queries with string concatenation โ use ORM or placeholders.
Secrets in environment variables: Never commit .env or API keys. Use secret managers in production.
npm audit: Run weekly. Fix vulnerabilities in dependencies.
Don't run as root: Create a non-root user for your Node.js process in production.
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
app.use(helmet()); // sets 15 security headers instantly!
app.use('/api/auth', rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts max
message: { error: 'Too many login attempts' }
}));
What is CORS and how do you configure it in Node.js?
Simple answer ๐ก CORS (Cross-Origin Resource Sharing) is a browser security rule that blocks your frontend from calling an API on a different domain โ unless the API explicitly says it's allowed. You configure this on the Node.js server.
const cors = require('cors');
// Allow specific origins only (production):
app.use(cors({
origin: ['https://myapp.com', 'https://admin.myapp.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // allow cookies / Authorization header
}));
// Dynamic origin check:
app.use(cors({
origin: (origin, callback) => {
const allowed = ['https://myapp.com', 'https://app2.com'];
if (!origin || allowed.includes(origin)) {
callback(null, true); // allow
} else {
callback(new Error('Not allowed by CORS'));
}
}
}));
Never do this in production โ ๏ธ app.use(cors()) with no options allows ALL origins โ any website on the internet can call your API! Always specify exactly which origins you trust.
What is connection pooling and why is it critical?
Simple answer ๐ก Opening a fresh database connection for every request is slow (50-200ms) and resource-intensive. Connection pooling keeps a set of open connections ready to reuse โ no wait time, much faster.
Analogy ๐ Summoning an Uber for every trip vs having 10 taxis sitting outside your building. The taxis (pool) are always ready โ no 5-minute wait for a new car. Connection pooling is the fleet of pre-warmed database connections.
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // max 10 connections in the pool
min: 2, // keep at least 2 warm at all times
idleTimeoutMillis: 30000, // close idle connections after 30s
connectionTimeoutMillis: 2000, // error if can't get connection in 2s
});
// Uses a connection from pool, returns it when done:
const result = await pool.query('SELECT * FROM users WHERE id = $1', [1]);
// Mongoose also pools connections โ the default is 5:
await mongoose.connect(uri, { maxPoolSize: 10 });
// ALWAYS close pool on shutdown:
process.on('SIGTERM', async () => {
await pool.end();
process.exit(0);
});
What is the N+1 query problem and how do you fix it?
Simple answer ๐ก The N+1 problem: you fetch N records, then make 1 extra database query for EACH record to get related data. 100 users = 101 queries instead of 2. This is a silent performance killer in Node.js apps.
// N+1 PROBLEM โ 1 + N queries:
const users = await User.find(); // 1 query
for (const user of users) {
user.posts = await Post.find({ userId: user._id }); // N queries!
}
// 100 users = 101 queries! Slow!
// FIX 1 โ Mongoose populate (2 queries total):
const users = await User.find().populate('posts');
// FIX 2 โ batch load manually (2 queries total):
const users = await User.find();
const ids = users.map(u => u._id);
const posts = await Post.find({ userId: { $in: ids } }); // 1 query for all!
// Group posts by userId:
const postMap = posts.reduce((map, post) => {
(map[post.userId] ??= []).push(post);
return map;
}, {});
users.forEach(user => { user.posts = postMap[user._id] || []; });
// 2 queries total โ
What is caching and how do you implement it with Redis?
Simple answer ๐ก Caching stores frequently accessed data in fast memory so you don't hit the database every time. Redis is the standard caching layer in Node.js โ it's in-memory (fast!) and supports TTL (automatic expiry).
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
// Cache-aside pattern (most common):
async function getUser(id) {
// 1. Check cache first:
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached); // cache hit โ fast!
// 2. Cache miss โ fetch from database:
const user = await User.findById(id);
if (!user) return null;
// 3. Store in cache with 5-minute expiry:
await redis.setex(`user:${id}`, 300, JSON.stringify(user));
return user;
}
// When user is updated โ invalidate the cache!
async function updateUser(id, data) {
const user = await User.findByIdAndUpdate(id, data, { new: true });
await redis.del(`user:${id}`); // โ stale cache removed!
return user;
}
// Useful Redis commands:
await redis.set('key', 'value'); // set
await redis.get('key'); // get
await redis.setex('key', 300, 'value'); // set with 5-min TTL
await redis.del('key'); // delete
await redis.ttl('key'); // remaining lifetime in seconds
What are database transactions and when do you need them?
Simple answer ๐ก A transaction groups multiple database operations so they either ALL succeed or ALL fail (rollback). Critical for operations that must be atomic โ like transferring money, where deducting and crediting must both succeed or neither should.
// MongoDB transaction with Mongoose:
const session = await mongoose.startSession();
session.startTransaction();
try {
// Both operations are part of the SAME transaction:
await Account.updateOne(
{ userId: senderId },
{ $inc: { balance: -amount } },
{ session }
);
await Account.updateOne(
{ userId: receiverId },
{ $inc: { balance: +amount } },
{ session }
);
await session.commitTransaction(); // both succeed โ commit!
console.log('Transfer complete');
} catch (error) {
await session.abortTransaction(); // any failure โ rollback!
console.log('Transfer failed โ rolled back');
throw error;
} finally {
session.endSession();
}
// Without transaction: if server crashes after deducting but before
// crediting โ money disappears! Transaction prevents this.
What is the child_process module and when do you use it?
Simple answer ๐ก The child_process module lets you spawn other programs from Node.js โ run shell commands, execute Python scripts, or start other Node processes. It's how Node.js reaches outside its own environment.
const { exec, spawn, fork } = require('child_process');
// exec โ run command, buffer all output (for small output):
const { promisify } = require('util');
const execAsync = promisify(exec);
const { stdout } = await execAsync('git log --oneline -5');
console.log(stdout);
// spawn โ stream output (for large output or long-running):
const ls = spawn('ls', ['-la', '/home']);
ls.stdout.on('data', d => process.stdout.write(d));
ls.on('close', code => console.log(`Exited with code ${code}`));
// fork โ specifically for Node.js child processes, has IPC:
const child = fork('./worker.js');
child.send({ task: 'heavy-compute', data: bigArray });
child.on('message', (result) => console.log('Done:', result));
exec vs spawn vs fork ๐ exec: runs shell command, buffers output (good for small commands, vulnerable to shell injection with user input!).
spawn: runs command directly, streams output (good for long output, no shell injection risk).
fork: only for Node.js files, has built-in 2-way messaging via process.send().
What is AsyncLocalStorage and why is it useful?
Simple answer ๐ก AsyncLocalStorage stores data that follows an async chain automatically โ like a request ID or user session โ without passing it through every function call. It's how you associate data with a specific request across the entire async chain.
const { AsyncLocalStorage } = require('async_hooks');
const requestStorage = new AsyncLocalStorage();
// Middleware โ attach a request ID to the context:
app.use((req, res, next) => {
const requestId = crypto.randomUUID();
requestStorage.run({ requestId, userId: req.user?.id }, () => {
next(); // everything async inside this runs with this context!
});
});
// Deep inside a service or utility โ no need to pass requestId!
function logMessage(message) {
const ctx = requestStorage.getStore(); // get the current context
console.log(`[\({ctx?.requestId}] [user:\){ctx?.userId}] ${message}`);
}
// Without AsyncLocalStorage, you'd pass requestId to every function:
// fetchUser(id, requestId) โ getProfile(userId, requestId) โ log(requestId)
// Messy! AsyncLocalStorage eliminates this prop-drilling for async context.
What are job queues and why are they essential?
Simple answer ๐ก A job queue offloads slow or heavy tasks (emails, image resizing, PDF generation) to background workers โ so your API responds instantly without waiting. The API adds a job to the queue and returns immediately. Workers process jobs in the background.
// Using BullMQ (backed by Redis):
const { Queue, Worker } = require('bullmq');
const connection = { host: 'localhost', port: 6379 };
// In your API route โ add job and respond immediately:
const emailQueue = new Queue('emails', { connection });
app.post('/register', async (req, res) => {
const user = await User.create(req.body);
// Don't wait for email โ just queue it!
await emailQueue.add('welcome', {
to: user.email,
name: user.name
});
res.status(201).json({ user }); // responds in {
if (job.name === 'welcome') {
await sendWelcomeEmail(job.data.to, job.data.name);
}
}, { connection });
worker.on('completed', job => console.log(`Email sent: ${job.id}`));
worker.on('failed', (job, err) => console.error(`Failed: ${err.message}`));
Common use cases for queues ๐ Welcome/notification emails, password reset emails, image/video processing, PDF generation, data imports, payment webhooks, sending bulk notifications.
What is WebSocket and how do you implement real-time features?
Simple answer ๐ก WebSocket is a protocol for two-way real-time communication โ both client and server can send messages anytime without re-requesting. Unlike HTTP (client asks, server answers), WebSocket keeps the connection permanently open.
// Using Socket.IO (easier, handles fallbacks):
const { Server } = require('socket.io');
const httpServer = require('http').createServer(app);
const io = new Server(httpServer, { cors: { origin: '*' } });
io.on('connection', (socket) => {
console.log(`Connected: ${socket.id}`);
// Receive messages from THIS client:
socket.on('chat:send', ({ room, message }) => {
// Send to everyone in the room:
io.to(room).emit('chat:message', {
from: socket.id, message, time: Date.now()
});
});
// Joining rooms:
socket.on('room:join', (room) => {
socket.join(room);
socket.to(room).emit('user:joined', socket.id);
});
socket.on('disconnect', () => {
console.log(`Disconnected: ${socket.id}`);
});
});
Use cases for WebSocket ๐ Live chat apps, collaborative document editing (Google Docs style), live dashboards (stock prices, sports scores), multiplayer games, notifications, live code collaboration.
What is rate limiting and how do you implement it?
Simple answer ๐ก Rate limiting restricts how many requests a client can make in a time window. It protects against DDoS attacks, API abuse, and brute-force login attempts.
const rateLimit = require('express-rate-limit');
// General API rate limit:
app.use('/api/', rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true, // send RateLimit-* headers
message: { error: 'Too many requests, try again in 15 minutes' }
}));
// Strict limit for auth endpoints:
app.use('/api/auth', rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // only 5 login attempts per 15 min!
skipSuccessfulRequests: true, // don't count successful logins
}));
// For multiple server instances โ use Redis store:
const RedisStore = require('rate-limit-redis');
app.use(rateLimit({
store: new RedisStore({ sendCommand: (...args) => redis.call(...args) }),
windowMs: 15 * 60 * 1000,
max: 100
// This counts requests across ALL server instances!
}));
How do you schedule recurring tasks (cron jobs) in Node.js?
Simple answer ๐ก Use node-cron to schedule tasks โ clean up expired sessions, send daily reports, sync data every hour. The cron syntax defines when the task runs.
const cron = require('node-cron');
// Cron syntax: second minute hour day month weekday
// * * * * * *
// Every day at midnight:
cron.schedule('0 0 * * *', async () => {
await deleteExpiredSessions();
await cleanupOldLogs();
});
// Every Monday at 9am:
cron.schedule('0 9 * * 1', async () => {
await generateWeeklyReport();
await sendReportEmails();
});
// Every 30 seconds:
cron.schedule('*/30 * * * * *', () => {
checkSystemHealth();
});
Multi-instance problem โ ๏ธ If you have 4 server instances (Cluster), your cron job runs on ALL 4 โ sending the email 4 times! Fix: use BullMQ with repeat option (Redis ensures only one instance runs it), or use a distributed lock with Redis.
What is input validation and how do you do it properly?
Simple answer ๐ก Always validate user input before using it โ never trust what comes from the client. Use Joi or Zod to define a schema and validate against it. Invalid data returns a 400 error before it touches your business logic or database.
const Joi = require('joi');
const createUserSchema = Joi.object({
name: Joi.string().min(2).max(50).required(),
email: Joi.string().email().required(),
age: Joi.number().integer().min(0).max(120),
role: Joi.string().valid('user', 'admin').default('user')
});
// Validation middleware factory:
function validate(schema) {
return (req, res, next) => {
const { error, value } = schema.validate(req.body, {
abortEarly: false, // return ALL errors, not just the first
stripUnknown: true // remove any fields not in schema!
});
if (error) {
return res.status(400).json({
error: 'Validation failed',
details: error.details.map(d => d.message)
});
}
req.body = value; // use the cleaned, validated data
next();
};
}
app.post('/users', validate(createUserSchema), createUser);
What is the Repository Pattern and why use it?
Simple answer ๐ก The Repository Pattern separates your business logic from data access code. Instead of calling the ORM/database directly in your service, you go through a repository class. This makes testing easy (inject a fake repository) and swapping databases painless.
// Repository โ all database operations for User:
class UserRepository {
async findById(id) { return User.findById(id).lean(); }
async findByEmail(email) { return User.findOne({ email }).lean(); }
async create(data) { return User.create(data); }
async update(id, data) { return User.findByIdAndUpdate(id, data, { new: true }); }
async delete(id) { return User.findByIdAndDelete(id); }
}
// Service โ business logic, uses repository:
class UserService {
constructor(userRepo) {
this.userRepo = userRepo; // injected โ easy to mock in tests!
}
async register(email, password) {
const exists = await this.userRepo.findByEmail(email);
if (exists) throw new AppError('Email already taken', 409);
const hashed = await bcrypt.hash(password, 12);
return this.userRepo.create({ email, password: hashed });
}
}
// Production:
const service = new UserService(new UserRepository());
// Testing โ inject a fake:
const fakeRepo = { create: async (d) => d, findByEmail: async () => null };
const service = new UserService(fakeRepo); // no real database!
What is dependency injection and how does it help in Node.js?
Simple answer ๐ก Dependency injection means passing dependencies (database, email service, logger) into a class or function instead of creating them inside. This makes code testable โ you can inject mocks instead of real services during tests.
// WITHOUT DI โ hard to test (can't swap DB):
class UserService {
async getUser(id) {
return await User.findById(id); // directly uses Mongoose!
}
}
// WITH DI โ easy to test (inject anything):
class UserService {
constructor(db, emailService, logger) {
this.db = db;
this.email = emailService;
this.logger = logger;
}
async registerUser(data) {
const user = await this.db.users.create(data);
await this.email.sendWelcome(user.email);
this.logger.info(`User registered: ${user.id}`);
return user;
}
}
// Production:
const service = new UserService(mongoDb, sendGridEmail, winstonLogger);
// Tests โ inject fakes:
const fakeDb = { users: { create: async d => ({ ...d, id: '1' }) } };
const fakeEmail = { sendWelcome: async () => {} };
const fakeLogger = { info: () => {} };
const service = new UserService(fakeDb, fakeEmail, fakeLogger);
// Fast tests, no network calls, no database needed!
What is the Circuit Breaker pattern in Node.js?
Simple answer ๐ก A circuit breaker stops calling a failing external service after too many errors โ instead of every request failing and waiting for timeout, it "opens the circuit" and returns a fast error. After a timeout, it carefully tests if the service recovered.
Three states ๐ Closed (normal): All requests go through. Failures are tracked.
Open (failing): After too many failures, reject requests immediately. Don't even try.
Half-Open (testing): After timeout, let 1 request through. If it succeeds โ Closed. If it fails โ back to Open.
const Opossum = require('opossum');
const options = {
timeout: 3000, // 3s timeout = failure
errorThresholdPercentage: 50, // 50% failures = open circuit
resetTimeout: 30000 // try again after 30s
};
const breaker = new Opossum(callPaymentService, options);
breaker.on('open', () => console.warn('Circuit OPEN!'));
breaker.on('halfOpen', () => console.log('Testing recovery...'));
breaker.on('close', () => console.log('Circuit CLOSED โ normal!'));
breaker.fallback(() => ({ status: 'payment queued for retry' }));
// Use instead of calling directly:
const result = await breaker.fire({ amount: 99.99, card: '****1234' });
// If service is down โ returns fallback instantly!
What is PM2 and why use it in production?
Simple answer ๐ก PM2 is a process manager for Node.js. It keeps your app alive (auto-restarts on crash), runs multiple instances (cluster mode), handles logs, and lets you deploy with zero downtime. The standard tool for production Node.js deployments.
# Install globally:
npm install pm2 -g
# Start in cluster mode โ one worker per CPU core:
pm2 start app.js -i max --name "my-api"
# Daily restart (prevents memory leaks accumulating):
pm2 start app.js --cron-restart="0 3 * * *"
# Useful PM2 commands:
pm2 list # see all processes and status
pm2 logs my-api # view live logs
pm2 monit # live monitoring dashboard
pm2 restart my-api # restart gracefully
pm2 reload my-api # zero-downtime reload!
pm2 stop my-api # stop
pm2 delete my-api # remove from PM2
# Auto-start on server reboot:
pm2 save # save current process list
pm2 startup # generate startup script
# Follow the output instructions!
What is the Observer pattern with EventEmitter โ practical patterns?
Simple answer ๐ก The Observer pattern with EventEmitter lets you decouple services. When something happens (order placed, user registered), emit an event. Other services react without the emitter knowing about them. New behavior = just add a new listener โ don't touch the emitter.
// This is the Open/Closed Principle in action:
class OrderService extends EventEmitter {
async placeOrder(orderData) {
const order = await Order.create(orderData);
this.emit('order:placed', order); // fire and forget!
return order;
// OrderService has NO idea who's listening or what they do!
}
}
const orders = new OrderService();
// Add behaviors independently โ no changes to OrderService:
orders.on('order:placed', async o => await emailService.sendConfirmation(o));
orders.on('order:placed', async o => await inventory.reserve(o.items));
orders.on('order:placed', async o => await analytics.track('purchase', o));
// 6 months later โ add a new behavior:
orders.on('order:placed', async o => await loyaltyService.addPoints(o));
// Zero changes to OrderService! Completely decoupled.
What is logging in production Node.js and what should you use?
Simple answer ๐ก Use a proper logging library like Winston or Pino instead of console.log. They support log levels, structured JSON output (great for log aggregators), and multiple destinations.
const winston = require('winston');
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json() // structured JSON โ searchable in Datadog/Splunk!
),
transports: [
new winston.transports.Console({
format: process.env.NODE_ENV === 'development'
? winston.format.simple() // human-readable in dev
: winston.format.json() // JSON in production
}),
new winston.transports.File({ filename: 'error.log', level: 'error' })
]
});
// Use log levels appropriately:
logger.error('DB connection failed', { error: err.message, stack: err.stack });
logger.warn('Rate limit almost reached', { ip: req.ip, count: 95 });
logger.info('User registered', { userId: user.id, email: user.email });
logger.debug('Cache hit', { key, ttl }); // only shows in dev
What is the difference between synchronous and asynchronous error handling patterns?
Simple answer ๐ก Synchronous errors are caught with try/catch. Async errors (rejected Promises) need try/catch inside async functions or .catch() on Promises. The most common Node.js bug is mixing them up and letting async errors go unhandled.
// Sync error โ caught by try/catch:
try { JSON.parse("{invalid}"); }
catch (err) { console.log("caught:", err.message); }
// Async error โ try/catch with await:
try { await failingOperation(); }
catch (err) { console.log("caught:", err.message); }
// CLASSIC MISTAKE โ try/catch can't catch callback errors:
try {
someCallbackAPI('data', function(err, result) {
if (err) throw err; // โ This throw is NOT caught by outer try/catch!
// It becomes an uncaughtException!
});
} catch (err) {} // never runs!
// FIX:
someCallbackAPI('data', function(err, result) {
if (err) return handleError(err); // handle inside the callback!
});
// Properly catching Promise errors โ ALWAYS do one of these:
myPromise.catch(err => handleError(err)); // .catch()
const result = await myPromise.catch(handleError); // inline catch
try { await myPromise; } catch (err) { ... } // try/catch
What is pagination and how do you implement it properly?
Simple answer ๐ก Never return all records at once โ always paginate. Two approaches: offset-based (simple, but slow for large datasets) and cursor-based (fast, scales to billions of records).
// Offset-based pagination:
app.get('/api/users', async (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const skip = (page - 1) * limit;
const [users, total] = await Promise.all([
User.find().skip(skip).limit(limit).sort({ createdAt: -1 }),
User.countDocuments()
]);
res.json({
data: users,
pagination: {
page, limit, total,
totalPages: Math.ceil(total / limit),
hasNext: page * limit 1
}
});
});
// Cursor-based (better for large/real-time data):
app.get('/api/feed', async (req, res) => {
const cursor = req.query.cursor; // last seen item's ID
const limit = 20;
const query = cursor ? { _id: { $lt: cursor } } : {};
const items = await Post.find(query).limit(limit).sort({ _id: -1 });
res.json({
data: items,
nextCursor: items.length === limit ? items.at(-1)._id : null
});
});
How do you test Node.js/Express APIs?
Simple answer ๐ก Use Jest as the test framework and Supertest to make real HTTP requests to your Express app in tests โ without running an actual server on a port.
// app.js โ export app WITHOUT calling listen():
const express = require('express');
const app = express();
// ... routes ...
module.exports = app;
// server.js โ entry point:
const app = require('./app');
app.listen(3000);
// users.test.js โ test the app:
const request = require('supertest');
const app = require('./app');
describe('Users API', () => {
it('GET /api/users returns 200 with array', async () => {
const res = await request(app).get('/api/users');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.data)).toBe(true);
});
it('POST /api/users creates user', async () => {
const res = await request(app)
.post('/api/users')
.set('Authorization', 'Bearer valid-token')
.send({ name: 'Alice', email: 'alice@test.com' });
expect(res.status).toBe(201);
expect(res.body.name).toBe('Alice');
});
it('POST /api/users returns 400 for missing email', async () => {
const res = await request(app).post('/api/users').send({ name: 'Bob' });
expect(res.status).toBe(400);
});
});
What is mocking in tests and how do you mock database calls?
Simple answer ๐ก Mocking replaces real dependencies (database, external APIs) with fake ones during tests. This makes tests fast (no real network/DB), reliable (no flakiness), and isolated (one thing fails = one test fails).
// users.service.js:
const User = require('./models/User');
exports.getUserById = async (id) => {
return User.findById(id);
};
// users.service.test.js โ mock the User model:
jest.mock('./models/User');
const userService = require('./users.service');
describe('getUserById', () => {
it('returns user when found', async () => {
const fakeUser = { id: '123', name: 'Alice' };
User.findById.mockResolvedValue(fakeUser); // fake DB response!
const result = await userService.getUserById('123');
expect(result).toEqual(fakeUser);
expect(User.findById).toHaveBeenCalledWith('123');
});
it('returns null when not found', async () => {
User.findById.mockResolvedValue(null);
const result = await userService.getUserById('999');
expect(result).toBeNull();
});
it('propagates DB errors', async () => {
User.findById.mockRejectedValue(new Error('DB Error'));
await expect(userService.getUserById('1')).rejects.toThrow('DB Error');
});
});
What are environment variables and how do you manage them?
Simple answer ๐ก Environment variables store configuration and secrets outside your code. Database URLs, API keys, JWT secrets โ these change between environments (dev/staging/production) and should NEVER be committed to git.
# .env file (local dev only โ in .gitignore!):
PORT=3000
NODE_ENV=development
DATABASE_URL=mongodb://localhost:27017/myapp
JWT_SECRET=dev_secret_change_in_production
SENDGRID_API_KEY=SG.xxxxx
# Load in your app:
require('dotenv').config();
const port = process.env.PORT || 3000;
const db = process.env.DATABASE_URL;
// Validate required vars on startup โ fail fast!
const required = ['DATABASE_URL', 'JWT_SECRET', 'SENDGRID_API_KEY'];
required.forEach(key => {
if (!process.env[key]) {
console.error(`โ Missing required env var: ${key}`);
process.exit(1); // fail loudly before serving any traffic!
}
});
In production โ don't use .env files ๐ Use: AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, Vercel/Railway environment UI, Kubernetes Secrets, or Docker secrets. These are encrypted, audited, and rotatable. A .env file on disk is a security risk.
What is the proper folder structure for a Node.js project?
Simple answer ๐ก A clean structure separates concerns โ routes define endpoints, controllers handle requests, services contain business logic, and repositories manage database access. This makes testing, debugging, and scaling much easier.
project/
โโโ src/
โ โโโ config/ # DB setup, env validation
โ โ โโโ database.js # mongoose.connect or pg Pool
โ โ โโโ env.js # validate required env vars
โ โโโ controllers/ # thin โ receive req, call service, send res
โ โ โโโ users.controller.js
โ โโโ middleware/ # auth, validation, rate limit, logger
โ โ โโโ auth.middleware.js
โ โ โโโ validate.middleware.js
โ โโโ models/ # Mongoose/Prisma schemas
โ โ โโโ User.js
โ โโโ repositories/ # all database operations
โ โ โโโ users.repository.js
โ โโโ routes/ # route definitions โ map URL to controller
โ โ โโโ users.routes.js
โ โโโ services/ # business logic โ fat, calls repositories
โ โ โโโ users.service.js
โ โโโ utils/ # helpers: email, crypto, formatters
โ โโโ app.js # Express setup โ NO app.listen() here!
โโโ tests/ # mirrors src/ structure
โโโ .env.example # template (no real values)
โโโ .gitignore # includes node_modules, .env
โโโ package.json
โโโ server.js # entry point: require('./src/app').listen(PORT)
Why separate app.js from server.js? ๐ฏ Your tests import app.js directly and Supertest binds it to a random port. If you call listen() inside app.js, every test starts the server โ port conflicts, slow tests. Separating them keeps tests clean and fast.
What is the difference between horizontal and vertical scaling in Node.js?
Simple answer ๐ก Vertical scaling: make the server bigger (more RAM, faster CPU). Hit a ceiling. Horizontal scaling: add more servers running the same app behind a load balancer. Node.js is excellent for horizontal scaling โ it's lightweight (30-100MB RAM) and starts in seconds.
Scaling ladder for Node.js ๐ช Level 1 โ PM2 Cluster: Use all CPU cores on one server. Free. Works for moderate traffic.
Level 2 โ Multiple servers + Nginx: Nginx load-balances traffic across 3 Node.js servers. Need shared state (Redis for sessions, DB for data).
Level 3 โ Container orchestration (Kubernetes): Auto-scale based on CPU/memory metrics. Add pods automatically under load. Remove when quiet. Self-healing (restarts crashed pods).
Level 4 โ Microservices: Scale each service independently. User service getting hammered? Scale it from 3 to 10 instances โ without touching the payment service.
What is Server-Sent Events (SSE) and how is it different from WebSockets?
Simple answer ๐ก SSE is a one-way real-time channel โ server pushes updates to the client over a regular HTTP connection. WebSockets are two-way. SSE is simpler to implement, auto-reconnects, and works over HTTP/2 natively.
SSE vs WebSocket ๐ SSE: server โ client only. HTTP-based. Auto-reconnect. Works through proxies. Great for: live feeds, notifications, progress updates.
WebSocket: two-way. Separate protocol (ws://). Must handle reconnects. Great for: chat, gaming, collaborative editing.
// SSE server โ stream events to client:
app.get('/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders(); // send headers immediately
// Push an event every second:
const interval = setInterval(() => {
const data = JSON.stringify({ time: new Date(), count: ++counter });
res.write(`event: update\n`); // event name
res.write(`data: ${data}\n\n`); // data (double newline = end of event!)
}, 1000);
// Clean up when client disconnects:
req.on('close', () => {
clearInterval(interval);
res.end();
});
});
What are common Node.js performance anti-patterns to avoid?
Things that kill Node.js performance ๐ก
Top anti-patterns & fixes ๐ โ Blocking the event loop: CPU-heavy sync code in route handlers. Fix: Worker Threads, offload to queue.
โ readFileSync/writeFileSync in routes: Blocks ALL requests while reading. Fix: always use async fs.promises.
โ No database indexes: Full table scans on every query. Fix: index queried fields โ can be 1000x faster.
โ N+1 queries: 101 queries instead of 2. Fix: populate, joins, batch loading.
โ Loading entire datasets into memory: User.find() returns 1 million records. Fix: pagination, streams, cursors.
โ No connection pooling: New DB connection per request. Fix: pg Pool, Mongoose pools automatically.
โ Unhandled promise rejections: Silent failures, undefined behavior. Fix: always catch, set up global handler.
โ No graceful shutdown: Requests dropped on deploy. Fix: handle SIGTERM, close server first.
What is TypeScript with Node.js and why should you use it?
Simple answer ๐ก TypeScript adds static types to JavaScript. Your editor catches type errors before you run the code. In Node.js, server-side bugs affect all users โ TypeScript prevents an entire class of runtime errors before they reach production.
// TypeScript catches errors at write time:
interface User {
id: number;
name: string;
email: string;
createdAt: Date;
}
async function getUser(id: number): Promise<User | null> {
return db.findById(id); // TypeScript verifies return type!
}
// Express with types:
import { Request, Response, NextFunction } from 'express';
app.get('/users/:id', async (req: Request, res: Response, next: NextFunction) => {
const id = parseInt(req.params.id); // TypeScript: params are string
const user: User | null = await getUser(id);
if (!user) return res.status(404).json({ error: 'Not found' });
res.json(user); // TypeScript confirms user matches User interface
});
Benefits in Node.js ๐ Catch null reference crashes before production. Autocomplete for your API models. Safe refactoring โ find all breaking changes instantly. Self-documenting code โ types ARE the documentation. Industry standard for serious backends in 2024+.
What is npm audit and how do you handle security vulnerabilities?
Simple answer ๐ก npm audit scans your dependencies for known security vulnerabilities and reports them with severity levels. Run it regularly โ a transitive dependency (a package your package uses) could have a critical CVE.
# Check for vulnerabilities:
npm audit
# Auto-fix where possible:
npm audit fix
# Fix even breaking changes (major versions):
npm audit fix --force # careful โ may break things!
# See detailed report:
npm audit --json | jq '.vulnerabilities'
# In CI/CD โ fail the build if high vulnerabilities:
npm audit --audit-level=high
# Check specific package:
npm why lodash # why is this package in my dependencies?
# Keep dependencies updated (use a tool like Dependabot):
# GitHub โ Settings โ Security โ Dependabot alerts
# Creates PRs automatically when vulnerabilities are found!
Workflow ๐ Add npm audit --audit-level=moderate to your CI pipeline. This blocks deployment if medium+ severity vulnerabilities are found. Dependabot or Renovate automatically creates pull requests when new vulnerable versions are discovered and fixed versions are available.
What is distributed tracing and how do you add it to Node.js?
Simple answer ๐ก In a microservices system, one user request might touch 10 services. Distributed tracing follows that request's entire journey โ how long each service took, where errors occurred, which database calls were slow.
// OpenTelemetry โ the standard for distributed tracing:
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-otlp-http');
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({
url: process.env.OTLP_ENDPOINT // Jaeger, Datadog, Honeycomb, etc.
}),
instrumentations: [
getNodeAutoInstrumentations() // auto-instruments: http, express, mongodb!
]
});
sdk.start(); // MUST start before importing other modules!
// Now every request automatically gets a trace ID and spans!
// View complete request journey in your APM tool.
What you see with tracing ๐ Request โ API Gateway (5ms) โ User Service (12ms) โ MongoDB query (45ms) โ Redis cache write (2ms) โ Response (total: 64ms). Without tracing, debugging slow requests is guesswork. With tracing, you see exactly which step was slow.
What is the health check endpoint and why is it essential?
Simple answer ๐ก A health check endpoint (GET /health) lets load balancers, Kubernetes, and monitoring tools check if your app is working. If it returns an error, the orchestrator routes traffic away from that instance automatically.
app.get('/health', async (req, res) => {
const status = { status: 'ok', uptime: process.uptime(), checks: {} };
// Check database connectivity:
try {
await mongoose.connection.db.admin().ping();
status.checks.database = 'ok';
} catch (err) {
status.checks.database = 'error';
status.status = 'degraded';
}
// Check Redis:
try {
await redis.ping();
status.checks.redis = 'ok';
} catch (err) {
status.checks.redis = 'error';
status.status = 'degraded';
}
// Add memory check:
const { heapUsed, heapTotal } = process.memoryUsage();
status.checks.memory = `\({Math.round(heapUsed/1024/1024)}MB / \){Math.round(heapTotal/1024/1024)}MB`;
const code = status.status === 'ok' ? 200 : 503;
res.status(code).json(status);
});
What are API versioning strategies in Node.js?
Simple answer ๐ก API versioning lets you change your API without breaking existing clients. Old clients use v1, new clients use v2 โ both work simultaneously. The most common approach: version in the URL path.
// URL versioning (most common, most explicit):
app.use('/api/v1/users', v1UserRoutes);
app.use('/api/v2/users', v2UserRoutes);
// Header versioning (cleaner URLs, less discoverable):
app.use((req, res, next) => {
req.apiVersion = req.headers['accept-version'] || '1';
next();
});
app.get('/api/users', (req, res) => {
return req.apiVersion === '2' ? v2Handler(req, res) : v1Handler(req, res);
});
// Deprecation headers (warn clients before removing old version):
app.use('/api/v1', (req, res, next) => {
res.set('Deprecation', 'true');
res.set('Sunset', 'Sat, 31 Dec 2025 23:59:59 GMT');
res.set('Link', '<https://api.example.com/api/v2>; rel="successor-version"');
next();
});
What is session management vs JWT and which should you use?
Simple answer ๐ก Sessions store user state on the server, browser gets a session ID. JWTs store everything in the token, server is stateless. Each has real trade-offs โ choosing depends on your architecture.
Sessions ๐ JWT detailed comparison ๐ Sessions:
โ Easy to invalidate instantly โ delete from Redis/DB.
โ Small cookie โ just a session ID (not the user's data).
โ Requires server-side storage โ needs Redis for multi-server setups.
โ Every request requires a Redis lookup.
JWT:
โ Stateless โ no server storage needed, works across microservices.
โ Contains user info โ no DB lookup per request (faster!).
โ Hard to invalidate โ can't revoke a token before it expires (need a blocklist!).
โ Grows with payload size. If secret leaks, ALL tokens are compromised.
Use sessions: traditional web apps, when you need instant logout.
Use JWT: stateless APIs, microservices, mobile apps, third-party token sharing.
What is file upload handling and what are the security considerations?
Simple answer ๐ก Use Multer for handling file uploads in Express. Always validate file type, size, and name โ attackers will try to upload malicious files.
const multer = require('multer');
const path = require('path');
const storage = multer.diskStorage({
destination: 'uploads/',
filename: (req, file, cb) => {
// NEVER use the original filename โ path traversal attack!
const safeFilename = `\({Date.now()}-\){Math.random().toString(36)}`
+ path.extname(file.originalname).toLowerCase();
cb(null, safeFilename);
}
});
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB max
fileFilter: (req, file, cb) => {
// Validate MIME type โ check mimetype AND extension:
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
const allowedExts = ['.jpg', '.jpeg', '.png', '.webp'];
const ext = path.extname(file.originalname).toLowerCase();
if (allowedTypes.includes(file.mimetype) && allowedExts.includes(ext)) {
cb(null, true);
} else {
cb(new Error('Only JPEG, PNG, WebP images allowed'));
}
}
});
app.post('/upload', upload.single('photo'), (req, res) => {
res.json({ filename: req.file.filename });
});
What is database indexing and why does it matter for Node.js apps?
Simple answer ๐ก A database index is like a book's index โ instead of scanning every page to find a topic, you jump straight to it. Without indexes, the database scans every row (O(n)). With indexes, it finds records in O(log n). The difference: 2ms vs 2000ms for a million-row table.
// MongoDB โ add indexes in Mongoose schema:
const userSchema = new mongoose.Schema({
email: { type: String, unique: true }, // unique creates an index!
name: String,
age: Number,
role: String,
createdAt: { type: Date, default: Date.now }
});
userSchema.index({ email: 1 }); // single field index
userSchema.index({ name: 1, age: -1 }); // compound index
userSchema.index({ name: 'text' }); // full-text search index
userSchema.index({ createdAt: -1 }); // sort index for newest first
// PostgreSQL:
// CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
// CREATE INDEX idx_users_name_age ON users(name, age DESC);
// When to index:
// โ
Fields in WHERE clauses: WHERE email = '...'
// โ
Fields you ORDER BY: ORDER BY created_at DESC
// โ
Fields in JOIN conditions
// โ Don't index every field โ indexes slow down writes and use disk space!
What are microservices and how does Node.js fit in?
Simple answer ๐ก Microservices architecture splits an app into many small, independent services โ each responsible for one feature, deployed independently, scaling independently. Node.js is ideal: lightweight, fast startup, excellent I/O performance.
Example microservices breakdown ๐๏ธ Instead of ONE large monolith:
โ user-service: registration, login, profile.
โ product-service: catalog, inventory, pricing.
โ order-service: checkout, order history.
โ notification-service: emails, push notifications, SMS.
โ api-gateway: routes requests to correct service, handles auth.
Services communicate via: REST HTTP (simple), gRPC (fast, typed), or message queues (Kafka, RabbitMQ โ async, decoupled).
Node.js per service: 30-100MB RAM, starts in <1 second. Run hundreds of instances cheaply. Perfect for microservices!
What is compression and how does it improve API performance?
Simple answer ๐ก Compression reduces HTTP response sizes โ a 100KB JSON response becomes 20KB after gzip. This saves bandwidth and speeds up responses, especially on slow connections. One middleware line adds it to your entire Express app.
const compression = require('compression');
// Enable gzip/brotli for all responses:
app.use(compression({
level: 6, // compression level 1-9 (6 is the sweet spot)
threshold: 1024 // only compress responses > 1KB (small responses not worth it)
}));
// Effect: 100KB JSON โ ~15-25KB (75-85% reduction!)
// Most impactful on: large JSON responses, HTML, CSS, JS files
// DON'T compress: images (already compressed), videos, zip files
// The compression middleware automatically skips these MIME types
// In production with Nginx as reverse proxy:
// Let Nginx handle compression instead โ it's more efficient:
// gzip on;
// gzip_types text/plain application/json application/javascript text/css;
// gzip_min_length 1000;
What is request context and how do you pass it through your app?
Simple answer ๐ก Request context is data tied to a specific request โ user ID, request ID, tenant ID. You need it available throughout the entire async chain (controllers, services, repositories) without passing it as a parameter to every function.
const { AsyncLocalStorage } = require('async_hooks');
const als = new AsyncLocalStorage();
// Middleware โ create context for each request:
app.use((req, res, next) => {
als.run({
requestId: crypto.randomUUID(),
userId: req.user?.id,
tenantId: req.headers['x-tenant-id']
}, next);
});
// Anywhere in your codebase โ no prop drilling!
function getContext() { return als.getStore(); }
// Service (no need to receive requestId as parameter!):
class UserService {
async createUser(data) {
const { userId, requestId } = getContext();
logger.info('Creating user', { requestId, createdBy: userId });
const user = await this.repo.create(data);
logger.info('User created', { requestId, newUserId: user.id });
return user;
}
}
What is the SOLID principle applied to Node.js?
Simple answer ๐ก SOLID is 5 design principles that make Node.js code easier to test, extend, and maintain as your app grows.
SOLID in Node.js ๐ S โ Single Responsibility: UserController only handles HTTP. UserService only handles business logic. UserRepository only handles DB. One job each.
O โ Open/Closed: Add new behavior by adding new event listeners โ don't modify OrderService. "Open for extension, closed for modification."
L โ Liskov Substitution: MongoUserRepository and PostgresUserRepository implement the same interface. Either can replace the other in UserService.
I โ Interface Segregation: Don't make a service implement 20 methods it doesn't need. Split into focused interfaces.
D โ Dependency Inversion: UserService depends on IUserRepository (interface), not MongoUserRepository (concrete). Inject the implementation. Easy to swap, easy to test.
What is the difference between 'require' caching and singleton pattern?
Simple answer ๐ก Node.js caches every module after the first require(). The same module object is returned every time โ even from different files. This makes modules naturally behave as singletons.
// db.js โ database connection module:
const mongoose = require('mongoose');
let connection = null;
async function connect() {
if (!connection) {
connection = await mongoose.connect(process.env.MONGODB_URI);
console.log('DB connected'); // only runs ONCE!
}
return connection;
}
module.exports = { connect };
// app.js:
const db = require('./db');
await db.connect();
// controllers/users.js โ requires the same module:
const db = require('./db');
await db.connect(); // returns the cached connection โ no new connection!
// This is why database connection modules work as singletons:
// require() returns the exact same object every time!
Beware of singleton problems โ ๏ธ In tests, the cached module persists between test files! If one test modifies the singleton, other tests see the change. Fix: use jest.resetModules() or jest.isolateModules() between tests, or design modules to be resettable.
What is the difference between REST and GraphQL APIs in Node.js?
Simple answer ๐ก REST uses multiple endpoints, each returning a fixed data shape. GraphQL uses one endpoint where the client specifies exactly what data it wants. GraphQL solves over-fetching and under-fetching problems.
REST vs GraphQL ๐ REST: GET /users/1 returns ALL user fields even if you need only the name. Need the user's posts? Another request: GET /users/1/posts. Two round trips.
GraphQL: One request, ask for exactly what you need:
query {
user(id: 1) {
name # only name
posts { # and their posts
title # only title
}
}
}
When REST is better: simple CRUD, file uploads, caching is critical (REST responses are cacheable by URL).
When GraphQL is better: complex related data, many different client types (mobile vs web want different data), rapid frontend development.
What is debounce and throttle in the context of Node.js?
Simple answer ๐ก In Node.js backends, debounce and throttle prevent functions from running too frequently โ useful for batch processing, preventing duplicate API calls, and protecting external services from being overwhelmed.
// Debounce โ batch rapid events into one call:
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
// Example: user editing triggers save โ only save 500ms after they stop:
const debouncedSave = debounce(async (docId, content) => {
await Document.findByIdAndUpdate(docId, { content });
console.log('Saved!'); // only fires after user stops typing for 500ms
}, 500);
// Throttle โ run at most once per interval:
function throttle(fn, limit) {
let lastRun = 0;
return (...args) => {
const now = Date.now();
if (now - lastRun >= limit) {
lastRun = now;
return fn(...args);
}
};
}
// Throttle a webhook handler โ process at most once per second:
const throttledWebhook = throttle(processPaymentWebhook, 1000);
What is the difference between OAuth 2.0 and JWT authentication?
Simple answer ๐ก OAuth 2.0 is an authorization framework (a set of rules for getting permission). JWT is a token format (a way to carry permission). They're different things โ OAuth often uses JWT as its token format.
How they relate ๐ OAuth 2.0 defines the flow: "Redirect to Google โ User approves โ Google gives token โ Use token to call Google APIs." It's about the handshake protocol.
JWT defines the token shape: a base64-encoded JSON payload with a signature. It's about what the token looks like and how to verify it.
Combined: You implement OAuth to let users "Login with Google." Google gives back a JWT access token. You decode and verify the JWT to know who the user is.
Your own auth doesn't need OAuth โ you can issue your own JWTs directly after verifying username/password. OAuth is for when you need to let users grant access to third-party apps.
What are database migrations and how do you use them?
Simple answer ๐ก Migrations are versioned scripts that change your database schema over time. They run in order and track which have already been applied. Never change the production database schema manually โ always use migrations.
// Prisma migrations (recommended for new projects):
// 1. Change your schema.prisma:
// model User {
// id Int @id @default(autoincrement())
// email String @unique
// name String // โ new field
// }
// 2. Create migration:
// npx prisma migrate dev --name "add_name_to_users"
// โ Creates: migrations/20240101_add_name_to_users/migration.sql
// 3. Deploy to production:
// npx prisma migrate deploy
// Knex migrations (framework-agnostic):
// exports.up = knex => knex.schema.table('users', t => {
// t.string('name').notNullable().defaultTo('');
// t.index('email');
// });
// exports.down = knex => knex.schema.table('users', t => {
// t.dropColumn('name');
// });
Why migrations matter ๐๏ธ Without migrations: "Add a phone_number column to production" โ SSH in, run ALTER TABLE manually, pray nothing breaks, have no history of what changed. With migrations: commit the file, CI/CD runs it automatically, it's tracked in version control, reversible, and anyone can reproduce the database state exactly.
How do you implement retries with exponential backoff?
Simple answer ๐ก When calling external services that might fail temporarily (network blip, rate limit), retry with increasing delays between attempts. Exponential backoff prevents hammering an already struggling service.
// Retry with exponential backoff:
async function withRetry(fn, { maxAttempts = 3, initialDelay = 100 } = {}) {
let lastError;
for (let attempt = 1; attempt = 400 && err.status setTimeout(r, delay));
}
}
throw lastError;
}
// Usage:
const data = await withRetry(
() => callUnreliableAPI(),
{ maxAttempts: 4, initialDelay: 200 }
);
// Delays: 200ms, 400ms, 800ms โ then gives up
What is the difference between optimistic and pessimistic locking in databases?
Simple answer ๐ก When multiple requests might update the same data simultaneously, you need concurrency control. Pessimistic locking: lock the record before reading it โ nobody else can touch it until you're done. Optimistic locking: no lock, but detect if someone else changed it before you save.
// Optimistic locking with MongoDB (version field):
const productSchema = new mongoose.Schema({
name: String,
stock: Number,
__v: Number // Mongoose uses __v for optimistic locking!
});
// Update only if version matches โ fails if someone else updated first:
const result = await Product.findOneAndUpdate(
{ _id: productId, __v: currentVersion }, // version check!
{ \(inc: { stock: -quantity }, \)inc: { __v: 1 } },
{ new: true }
);
if (!result) {
// Another request updated this product first โ retry!
throw new Error('Concurrent update conflict โ please try again');
}
// Pessimistic locking with PostgreSQL (SELECT FOR UPDATE):
// BEGIN;
// SELECT * FROM products WHERE id = $1 FOR UPDATE; -- locks the row!
// UPDATE products SET stock = stock - \(2 WHERE id = \)1;
// COMMIT; -- releases the lock
How do you handle multi-tenancy in Node.js?
Simple answer ๐ก Multi-tenancy means one app instance serves multiple customers (tenants) with their data kept separate. The three strategies are: separate databases, shared database with tenant column, or schema isolation.
Three strategies ๐ข Database per tenant: Each customer gets their own database. Most isolation, most expensive. Good for: enterprise SaaS, GDPR compliance.
Shared DB, tenant_id column: All tenants' data in one DB, every table has a tenant_id. Cheapest, must be very careful to filter by tenant_id everywhere.
Schema per tenant: One database, separate schema per tenant (PostgreSQL). Middle ground.
// Shared DB approach โ middleware to set tenant context:
app.use(async (req, res, next) => {
const tenantId = req.headers['x-tenant-id'] || req.user?.tenantId;
if (!tenantId) return res.status(400).json({ error: 'Tenant required' });
req.tenantId = tenantId;
next();
});
// Always filter by tenantId in queries!
app.get('/api/products', async (req, res) => {
const products = await Product.find({ tenantId: req.tenantId }); // โ
res.json(products);
});
// Forgetting tenantId in one query = data leak! Use middleware/repository
// that automatically adds tenantId to every query.
What is the event-driven architecture in Node.js?
Simple answer ๐ก Event-driven architecture (EDA) means your app reacts to events instead of calling services directly. Services publish events, other services subscribe and react. This creates a loosely coupled system where services don't need to know about each other.
EDA vs Direct calls ๐ Direct call: UserService โ EmailService โ InventoryService โ AnalyticsService. All must work. If Analytics is down, the whole flow fails. Changes require updating UserService.
EDA: UserService emits user.registered. EmailService, InventoryService, and AnalyticsService each subscribe independently. If Analytics is down, it misses the event (or replays from queue). Adding new behavior means adding a new subscriber โ zero changes to UserService.
// Using a message queue (BullMQ) as the event bus:
const { Queue, Worker } = require('bullmq');
const eventBus = new Queue('events', { connection });
// Publisher โ UserService:
await eventBus.add('user.registered', { userId, email, name });
// Subscriber 1 โ EmailService (separate process):
new Worker('events', async job => {
if (job.name === 'user.registered') await sendWelcomeEmail(job.data);
}, { connection });
// Subscriber 2 โ AnalyticsService (separate process):
new Worker('events', async job => {
if (job.name === 'user.registered') await trackNewUser(job.data);
}, { connection });
What is idempotency and why is it important in APIs?
Simple answer ๐ก An idempotent operation produces the same result no matter how many times you repeat it. GET, PUT, DELETE are idempotent. POST is NOT โ calling it twice creates two records. In Node.js APIs, you should make critical operations idempotent to handle retries safely.
// Non-idempotent POST โ dangerous!
app.post('/api/payments', async (req, res) => {
const payment = await processPayment(req.body.amount);
// If client retries (network timeout), the user gets charged TWICE!
res.json(payment);
});
// Idempotent version โ safe to retry:
app.post('/api/payments', async (req, res) => {
const idempotencyKey = req.headers['idempotency-key'];
if (!idempotencyKey) return res.status(400).json({ error: 'Idempotency-Key required' });
// Check if we've seen this key before:
const existing = await redis.get(`payment:${idempotencyKey}`);
if (existing) return res.json(JSON.parse(existing)); // return cached result!
// First time โ process the payment:
const payment = await processPayment(req.body.amount);
// Cache result with 24h TTL:
await redis.setex(`payment:${idempotencyKey}`, 86400, JSON.stringify(payment));
res.json(payment);
// Now the client can safely retry โ gets same result, no double charge!
});
How do you profile Node.js applications to find bottlenecks?
Simple answer ๐ก Profiling helps you find WHERE your app is slow and WHY. Node.js has built-in profiling via --prof flag, the Inspector, and tools like Clinic.js and 0x for flame graphs.
# Method 1 โ V8 built-in profiler:
node --prof app.js
# ... run some requests ...
node --prof-process isolate-*.log > processed.txt
# Method 2 โ Node.js Inspector (Chrome DevTools):
node --inspect app.js
# Open Chrome โ chrome://inspect โ Click "inspect" on your app
# Go to "Performance" tab โ Record โ Profile CPU/Memory
# Method 3 โ clinic.js (easiest):
npm install -g clinic
clinic doctor -- node app.js # finds event loop delays
clinic flame -- node app.js # flame graph of CPU usage
clinic bubbleprof -- node app.js # async bottleneck visualization
# Method 4 โ 0x (flame graphs):
npm install -g 0x
0x app.js
# Generates an interactive flame graph showing where time is spent
What to look for ๐ Wide boxes in flame graphs = slow functions. Event loop delay > 10ms = something is blocking. Growing heap in memory profiler = potential leak. Many small gaps in async profiler = N+1 queries or too many sequential awaits.
What are the most common Node.js interview mistakes to avoid?
Common mistakes that fail interviews ๐ก
Top mistakes ๐ โ Blocking the event loop: CPU-heavy sync code in route handlers. Show you know: Worker Threads and job queues.
โ Uncaught Promise rejections: Missing .catch() or try/catch in async code. Always handle async errors.
โ Not understanding the event loop phases: Confusing nextTick and setImmediate. Know the 6 phases and microtask priority.
โ require() in wrong places: require() inside a function or loop โ runs every call, defeats caching. Always require at top level.
โ Storing secrets in code: Hardcoded API keys. Always use environment variables.
โ No input validation: Trusting client data. Always validate with Joi/Zod.
โ Missing error handling middleware: Errors leak stack traces to users. Always add a catch-all error handler in Express.
โ N+1 queries: Fetching related data in a loop. Use populate, joins, or batch loading.
What is the 'You might not need a framework' consideration?
Simple answer ๐ก Node.js's built-in http module can handle simple use cases. Knowing when to use Express vs Fastify vs bare http shows deep understanding. More framework = more overhead. Sometimes raw Node.js is the right answer.
// Raw Node.js HTTP โ no framework, maximum performance:
const http = require('http');
const server = http.createServer(async (req, res) => {
if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ status: 'ok' }));
}
if (req.method === 'GET' && req.url === '/users') {
const users = await getUsers();
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify(users));
}
res.writeHead(404);
res.end('Not Found');
});
server.listen(3000);
// When to use raw http: webhooks, health check servers, internal tools
// When to use Express: most API projects (mature, vast ecosystem)
// When to use Fastify: performance-critical APIs (2x faster than Express)
What is Fastify and how does it compare to Express?
Simple answer ๐ก Fastify is a newer Node.js web framework that is significantly faster than Express โ about 2x higher throughput in benchmarks. It has JSON schema validation built in, TypeScript support, and a modern plugin system.
Express vs Fastify ๐ Express: huge ecosystem, massive community, millions of tutorials, very mature. Slower. Minimal built-in features. Flexible.
Fastify: much faster (uses faster JSON serialization, pipelining), built-in schema validation, TypeScript-first, structured plugin system, newer.
Choose Express: team already knows it, lots of middleware needed, stability over performance, maximum ecosystem compatibility.
Choose Fastify: new project, performance matters, TypeScript project, want built-in validation.
// Fastify routes look similar to Express but with schema:
const fastify = require('fastify')({ logger: true });
fastify.post('/users', {
schema: {
body: {
type: 'object',
required: ['email', 'name'],
properties: {
email: { type: 'string', format: 'email' },
name: { type: 'string', minLength: 2 }
}
}
}
}, async (request, reply) => {
const user = await User.create(request.body);
return reply.status(201).send(user);
});
What is streaming JSON responses and why would you use it?
Simple answer ๐ก When returning very large JSON responses, streaming lets you send data to the client as it's produced โ instead of building the entire response in memory first. The client starts receiving data immediately.
const { Readable } = require('stream');
app.get('/api/export', async (req, res) => {
res.setHeader('Content-Type', 'application/json');
// Stream a large dataset as newline-delimited JSON:
res.write('['); // start JSON array
let first = true;
// Use a database cursor โ doesn't load all records into memory!
const cursor = User.find().lean().cursor();
for await (const user of cursor) {
if (!first) res.write(',');
res.write(JSON.stringify(user));
first = false;
}
res.write(']');
res.end();
});
// Or use a Transform stream for clean code:
const { pipeline } = require('stream/promises');
const { stringify } = require('JSONStream');
await pipeline(
User.find().lean().cursor(),
stringify('[', ',', ']'), // wraps in JSON array
res
);
What is the Singleton pattern in Node.js and how does module caching help?
Simple answer ๐ก The Singleton pattern ensures only one instance of a class/object exists. In Node.js, module caching automatically makes module-level objects singletons โ the first require() runs the module code, subsequent requires return the cached export.
// logger.js โ automatically a singleton due to module caching:
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [new winston.transports.Console()]
});
module.exports = logger;
// First require() โ creates logger. All subsequent requires โ same instance!
// redis-client.js โ singleton Redis connection:
const Redis = require('ioredis');
const client = new Redis(process.env.REDIS_URL);
client.on('connect', () => console.log('Redis connected'));
client.on('error', err => console.error('Redis error:', err));
module.exports = client;
// Every file that requires this gets the SAME Redis connection!
// No need for connection management โ Node.js handles it!
// Usage in multiple files:
const redis = require('./redis-client'); // same connection everywhere!
How do you implement a simple API gateway in Node.js?
Simple answer ๐ก An API gateway is a single entry point that routes requests to the right microservice. It handles cross-cutting concerns: auth, rate limiting, logging, and request routing โ so individual services don't need to.
const express = require('express');
const httpProxy = require('http-proxy-middleware');
const app = express();
// Cross-cutting concerns applied to ALL routes:
app.use(helmet());
app.use(cors({ origin: process.env.ALLOWED_ORIGINS }));
app.use(rateLimiter);
app.use(authenticate); // verify JWT once at the gateway!
// Route to microservices:
app.use('/api/users', httpProxy.createProxyMiddleware({
target: process.env.USER_SERVICE_URL,
changeOrigin: true,
pathRewrite: { '^/api/users': '' } // remove prefix before forwarding
}));
app.use('/api/products', httpProxy.createProxyMiddleware({
target: process.env.PRODUCT_SERVICE_URL,
changeOrigin: true,
pathRewrite: { '^/api/products': '' }
}));
app.use('/api/orders', httpProxy.createProxyMiddleware({
target: process.env.ORDER_SERVICE_URL,
changeOrigin: true
}));
app.listen(3000);
What are the most important Node.js concepts to master?
Master these and you're ready for any Node.js interview ๐ก
The core fundamentals ๐ ๐ Event Loop โ phases, microtasks vs macrotasks, nextTick vs setImmediate, why it enables high concurrency with one thread.
๐ฆ Modules โ CommonJS caching (singletons!), module.exports vs exports, ES Modules differences.
๐ Streams โ 4 types, pipe, backpressure, pipeline(), when to use over loading into memory.
๐ก EventEmitter โ on, emit, once, off, the special 'error' event, memory leak warning.
โณ Async patterns โ callbacks โ Promises โ async/await, sequential vs parallel, race conditions, error handling.
๐ Express middleware โ execution order, next(), error handler (4 params), routing.
๐ Security โ JWT, bcrypt, CORS, helmet, rate limiting, input validation, never trust client input.
๐ Performance โ Cluster, Worker Threads, connection pooling, caching, indexes, N+1 queries.
๐๏ธ Architecture โ Repository pattern, DI, graceful shutdown, project structure.
๐งช Testing โ Jest, Supertest, mocking database calls, unit vs integration tests.
