Node.js Interview Questions and Answers
This page covers the Node.js questions asked most often in back-end and full-stack interviews, from the event loop and npm basics to Express, authentication, streams, MySQL and production deployment. Questions are grouped by level and ordered from most to least common, so start with the fresher section and move on once you can explain each answer in your own words.
Node.js interview questions: fresher level
What is Node.js and why is it used?
Node.js is a JavaScript runtime that runs JavaScript outside the browser, built on Google's V8 engine and the libuv library. It is not a language and not a framework.
It is popular for back-end work because it uses an event-driven, non-blocking I/O model. While one request waits for a database or file, Node keeps serving other requests instead of sitting idle. That makes it a good fit for I/O-heavy apps such as REST APIs, real-time chat, streaming services and microservices. It also lets a team use one language, JavaScript, on both the front end and the back end, and gives access to the huge npm package ecosystem.
It is a weaker fit for long CPU-heavy work like video encoding, unless that work is moved to worker threads or a separate service.
Interview tip: Interviewers often follow up with "so is Node a framework?" Say no: Express is the framework, Node is the runtime.
Is Node.js single-threaded?
Your JavaScript code runs on a single main thread, but Node.js as a whole is not single-threaded.
The event loop and all your callbacks execute on one thread, so two pieces of your JavaScript never run at the same time. Behind the scenes, libuv keeps a thread pool (4 threads by default, configurable with the UV_THREADPOOL_SIZE environment variable) for work the operating system cannot do asynchronously, such as most file system calls, dns.lookup, some crypto functions and zlib compression. Network sockets do not use this pool; they use the operating system's own async mechanisms (epoll, kqueue or IOCP).
V8 also uses extra threads for garbage collection, and you can create more threads yourself with worker_threads.
Interview tip: A strong answer separates "my JavaScript runs on one thread" from "the runtime uses several threads".
What is the event loop in Node.js?
The event loop is the mechanism that lets Node.js run non-blocking code on a single thread by picking up completed tasks and running their callbacks one at a time.
When you start an async operation such as reading a file or querying a database, Node hands it to libuv or the operating system and moves on. When the operation finishes, its callback is queued. The event loop keeps checking these queues and runs the ready callbacks on the main thread. The process exits when there is nothing left to wait for.
console.log('start');
setTimeout(() => console.log('timer done'), 0);
console.log('end');
// start
// end
// timer doneEven with a 0 ms delay, the timer callback runs only after the current synchronous code has finished.
What is the difference between blocking and non-blocking I/O in Node.js?
Blocking I/O makes the main thread wait until the operation finishes; non-blocking I/O starts the operation and lets the thread continue, handling the result later in a callback or promise.
const fs = require('fs');
const data = fs.readFileSync('big.txt', 'utf8'); // blocks until done
console.log('after sync read');
fs.readFile('big.txt', 'utf8', (err, text) => { // does not block
if (err) return console.error(err);
console.log('file ready');
});
console.log('this prints before "file ready"');Because Node serves every request from one JavaScript thread, a blocking call inside a request handler freezes all other users until it finishes. Sync APIs are fine at startup, for example reading a config file once, or in small CLI scripts. In servers, use the async or promise versions.
What are npm and package.json?
npm is the default package manager for Node.js, and package.json is the file that describes your project and its dependencies.
package.json holds the project name and version, the entry point, the module type, the list of dependencies with version ranges, and scripts you run with npm run, for example npm run dev.
{
"name": "shop-api",
"version": "1.0.0",
"type": "module",
"scripts": { "start": "node server.js", "dev": "node --watch server.js" },
"dependencies": { "express": "^5.1.0" },
"devDependencies": { "eslint": "^9.0.0" }
}Version ranges follow semantic versioning: ^5.1.0 allows any 5.x.x from 5.1.0 up, while ~5.1.0 allows only 5.1.x patch updates. npm init -y creates a starter file.
What is the difference between dependencies and devDependencies?
dependencies are packages your app needs to run in production; devDependencies are only needed while developing, testing or building.
- dependencies: express, mysql2, bcrypt, jsonwebtoken. Installed with
npm install express. - devDependencies: nodemon, eslint, jest, typescript. Installed with
npm install -D jest.
On a production server you can skip dev packages with npm ci --omit=dev, which gives a smaller install and fewer packages to audit. If you publish a library, people who install it get only its dependencies, never its devDependencies. Putting a runtime package in devDependencies is a classic bug: everything works locally but the app crashes on the server with a 'Cannot find module' error.
Interview tip: A common follow-up is where TypeScript belongs: in devDependencies, because only the compiled JavaScript runs in production.
What is package-lock.json and should you commit it?
package-lock.json records the exact version of every installed package, including dependencies of dependencies, so every install produces the same tree. Yes, you should commit it.
package.json only stores ranges such as ^4.18.0, so two installs a month apart could pull different versions. The lock file pins exact versions, where each package was downloaded from, and an integrity hash to detect tampering.
npm installinstalls and may update the lock file whenpackage.jsonchanges.npm ciinstalls exactly what the lock file says, deletes any existingnode_modulesfirst, and fails if the lock file andpackage.jsondisagree. Use it in CI and deployments.
Never edit the lock file by hand.
What is the difference between CommonJS and ES modules in Node.js?
CommonJS uses require and module.exports and loads modules synchronously; ES modules use import and export, are the official JavaScript standard, and are loaded asynchronously with static structure.
// CommonJS (math.js / app.js)
module.exports = { add: (a, b) => a + b };
const { add } = require('./math');
// ES modules (math.mjs / app.mjs)
export const add = (a, b) => a + b;
import { add } from './math.mjs';- A
.jsfile is treated as ESM whenpackage.jsonhas"type": "module";.mjsis always ESM and.cjsis always CommonJS. - ESM supports top-level
await; CommonJS does not. __dirnameand__filenameexist only in CommonJS. In ESM useimport.meta.dirnameandimport.meta.filenamein recent Node versions, or derive them fromimport.meta.url.- In ESM, relative imports need the file extension.
Interview tip: Expect a follow-up: recent Node versions let require() load an ES module as long as it does not use top-level await.
What is Express.js and why use it with Node.js?
Express is a minimal web framework for Node.js that adds routing, middleware and helpful request and response methods on top of Node's built-in http module.
With the raw http module you would parse URLs, methods and bodies yourself. Express handles that so you can write clear route handlers:
const express = require('express');
const app = express();
app.use(express.json());
app.get('/api/users/:id', (req, res) => {
res.json({ id: req.params.id, name: 'Asha' });
});
app.listen(3000, () => console.log('Listening on 3000'));It is unopinionated: it does not force a folder structure, ORM or template engine, which is why it is the most common choice for REST APIs and the 'E' in the MERN stack.
What is middleware in Express?
Middleware is a function that runs during the request-response cycle with access to req, res and next, and either ends the response or calls next() to pass control on.
function logger(req, res, next) {
console.log(req.method, req.url);
next(); // without this (or a response) the request hangs
}
app.use(logger); // every request
app.use('/admin', requireAdmin); // only paths starting with /admin
app.get('/profile', auth, showProfile); // one routeMiddleware is used for logging, parsing bodies, authentication, CORS, rate limiting and error handling. Types include application-level (app.use), router-level (router.use), built-in (express.json(), express.static()), third-party (cors, helmet) and error-handling middleware with four arguments.
Interview tip: The usual trap: a middleware that neither sends a response nor calls next() leaves the request hanging until the client times out.
What are the req and res objects in Express?
req represents the incoming HTTP request and res represents the response you send back; Express extends Node's own request and response objects with extra helpers.
req.params: route parameters, for/users/:idit givesreq.params.id.req.query: the query string, for?page=2it givesreq.query.page(always a string).req.body: the parsed request body, available only after body-parsing middleware.req.headersandreq.get('Authorization'): request headers.res.status(201).json(obj),res.send(),res.redirect(),res.sendStatus(204),res.set(),res.cookie().
Send exactly one response per request. Calling res.json() twice throws a 'Cannot set headers after they are sent' error, which usually means a missing return after an early response.
How do you use environment variables in Node.js?
Environment variables are read from process.env; they keep secrets and settings such as database passwords, API keys and the port out of your source code.
// .env (never commit this file)
// PORT=4000
// DB_PASSWORD=secret
// run with: node --env-file=.env server.js
const port = Number(process.env.PORT) || 3000;
const dbPassword = process.env.DB_PASSWORD;Recent Node versions can load a .env file with the --env-file flag; older projects use the dotenv package (require('dotenv').config()). Remember that every value in process.env is a string, so convert numbers and booleans yourself. Add .env to .gitignore, commit a .env.example with dummy values, and set real values on the server or hosting dashboard.
Interview tip: If asked why "false" behaves as true, it is because process.env values are strings and any non-empty string is truthy.
What is an error-first callback?
An error-first callback is Node's traditional callback convention where the first argument is an error (or null if there was none) and the following arguments are the result.
const fs = require('fs');
fs.readFile('notes.txt', 'utf8', (err, data) => {
if (err) {
console.error('Could not read file:', err.message);
return;
}
console.log(data);
});This forces every caller to think about failure first. Nesting many of these leads to 'callback hell', so modern code uses promises and async/await. Most core modules now offer promise versions such as fs/promises, and util.promisify converts any error-first callback function into one that returns a promise.
What is a REST API and how do you design one in Node.js?
A REST API exposes resources (users, orders, products) at URLs and uses HTTP methods to act on them, with each request being stateless and usually exchanging JSON.
GET /api/products: list products.GET /api/products/42: get one product.POST /api/products: create a product, respond201 Created.PUT /api/products/42: replace it;PATCH /api/products/42: update some fields.DELETE /api/products/42: delete it, often responding204 No Content.
Good design habits: use plural nouns, not verbs, in URLs (/products, not /getProducts); nest only for clear relationships (/users/7/orders); support filtering and pagination with query strings (?page=2&limit=20); return correct status codes and a consistent error shape; and version the API (/api/v1). GET, PUT and DELETE should be idempotent: repeating them has the same effect as doing them once.
Which HTTP status codes should a Node.js API return?
Return the status code that matches what happened, so clients can react without parsing the message text.
200 OK: successful read or update.201 Created: a resource was created.204 No Content: success with no body, common for deletes.400 Bad Request: invalid input.401 Unauthorized: not logged in or invalid token.403 Forbidden: logged in but not allowed.404 Not Found: the resource does not exist.409 Conflict: for example, the email is already registered.422 Unprocessable Content: well-formed but failed validation (some teams use 400 for this).429 Too Many Requests: rate limit hit.500 Internal Server Error: unexpected bug.502/503: the upstream server or app is down or overloaded.
res.status(201).json({ id: newUser.id });
res.status(404).json({ error: 'User not found' });Interview tip: The 401 vs 403 difference is asked constantly: 401 means "who are you?", 403 means "I know who you are, but no".
Node.js interview questions: intermediate level
What is the difference between process.nextTick, setImmediate and setTimeout?
process.nextTick runs its callback right after the current operation, before the event loop continues; setImmediate runs in the check phase after I/O polling; setTimeout(fn, 0) runs in the timers phase after at least about 1 ms (Node turns a 0 delay into 1).
// CommonJS file
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
console.log('sync');
// sync, nextTick, promise, then timeout/immediate in EITHER orderIn a CommonJS main module, 'timeout' versus 'immediate' is not guaranteed; it depends on whether 1 ms has passed when the loop starts. Inside an I/O callback, such as in fs.readFile, setImmediate always runs first. In an ES module the promise can log before nextTick, because the module body itself runs inside a promise job. Recursive nextTick calls can starve I/O, so prefer setImmediate for deferring work.
Interview tip: Interviewers like the I/O-callback case because it is the only one where the timeout versus immediate order is deterministic.
What are the phases of the Node.js event loop?
Each turn of the event loop moves through fixed phases, each with its own queue of callbacks.
- timers: callbacks from
setTimeoutandsetIntervalwhose time has come. - pending callbacks: some system-level I/O callbacks deferred from the previous turn, such as certain TCP errors.
- idle, prepare: used internally by Node.
- poll: retrieves new I/O events and runs their callbacks; if nothing else is scheduled, the loop waits here for I/O.
- check:
setImmediatecallbacks. - close callbacks: close events, such as
socket.on('close').
Between every individual callback, Node drains the microtask queues: first all process.nextTick callbacks, then all resolved promise callbacks. So microtasks always run before the loop moves to the next callback or phase.
How does routing work in Express?
Routing maps an HTTP method and URL path to a handler; Express checks routes in the order they were defined and runs the first one that matches.
// routes/products.js
const router = require('express').Router();
router.get('/', listProducts); // GET /api/products?page=2
router.get('/:id', getProduct); // req.params.id
router.post('/', auth, createProduct); // middleware before the handler
router.route('/:id').put(updateProduct).delete(deleteProduct);
module.exports = router;
// app.js
app.use('/api/products', require('./routes/products'));express.Router() creates a mini app so you can group routes per resource in separate files and mount them under a prefix. Order matters: if /:id is defined before /featured, a request to /featured is treated as an id. Put specific paths before parameterised ones, and add a 404 handler after all routes.
Why does middleware order matter in Express?
Express runs middleware and routes strictly in the order they are registered, so anything a route depends on must be registered before that route.
app.use(helmet());
app.use(cors());
app.use(express.json()); // before routes that read req.body
app.use('/api', apiRouter);
app.use((req, res) => res.status(404).json({ error: 'Not found' }));
app.use(errorHandler); // error handler goes last- If
express.json()is registered after a route,req.bodyisundefinedinside that route. - An auth middleware placed after a route does not protect it.
- The 404 handler must come after all routes, or it catches everything.
- The error handler must come last, so errors from everything above reach it.
Once a middleware sends a response without calling next(), nothing after it runs for that request.
How does error-handling middleware work in Express?
Error-handling middleware is a normal middleware function with four parameters, (err, req, res, next); Express recognises it by that argument count and skips it for normal requests.
app.get('/users/:id', async (req, res, next) => {
const user = await findUser(req.params.id);
if (!user) return next(new NotFoundError('User not found'));
res.json(user);
});
app.use((err, req, res, next) => {
console.error(err);
const status = err.status || 500;
res.status(status).json({ error: status === 500 ? 'Something went wrong' : err.message });
});Calling next(err) with any argument other than 'route' or 'router' skips all remaining regular middleware and jumps to the error handlers. You must declare all four parameters even if you do not use next. Express 5 also forwards a rejected promise from an async handler automatically; in Express 4 you must catch it and call next(err) yourself.
Interview tip: Watch for the follow-up "why is my error handler never called?": usually it has three parameters or is registered before the routes.
How do you read the request body in Express?
Use body-parsing middleware: express.json() for JSON bodies and express.urlencoded() for HTML form posts. Without it, req.body is undefined.
app.use(express.json({ limit: '100kb' }));
app.use(express.urlencoded({ extended: true }));
app.post('/api/signup', (req, res) => {
const { name, email } = req.body;
res.status(201).json({ name, email });
});express.json() only parses requests whose Content-Type is application/json, so a client that forgets the header gets an empty body. The limit option protects against very large payloads. File uploads sent as multipart/form-data are not handled by these parsers; use a package such as multer. The older body-parser package is what these built-in functions are based on, so you rarely need to install it separately.
What is the difference between JWT and session-based authentication?
With sessions, the server stores login state and gives the browser only a session id cookie; with JWT, the server gives the client a signed token that carries the user data, and stores nothing.
- Sessions: state lives in server memory, Redis or a database. Logging out or banning a user is easy: delete the session. Scaling across servers needs a shared store.
- JWT: stateless; any server with the secret or public key can verify it, which suits APIs, mobile apps and microservices. Revoking a token before it expires is hard, so keep access tokens short-lived.
const jwt = require('jsonwebtoken');
const token = jwt.sign({ sub: user.id, role: 'admin' }, process.env.JWT_SECRET, { expiresIn: '15m' });
const payload = jwt.verify(token, process.env.JWT_SECRET); // throws if invalid or expiredA JWT is signed, not encrypted: anyone can decode the payload, so never put passwords or sensitive data in it.
Interview tip: Saying "JWT payloads are only base64url-encoded, not encrypted" is the detail many candidates miss.
How should you store user passwords in a Node.js app?
Never store plain or encrypted passwords; store a slow, salted hash made with an algorithm like bcrypt, scrypt or Argon2, and compare hashes at login.
const bcrypt = require('bcrypt');
// signup
const hash = await bcrypt.hash(plainPassword, 12); // 12 = cost factor
await db.execute('INSERT INTO users (email, password_hash) VALUES (?, ?)', [email, hash]);
// login
const ok = await bcrypt.compare(plainPassword, user.password_hash);
if (!ok) return res.status(401).json({ error: 'Invalid email or password' });bcrypt generates a random salt for every password and stores it inside the hash string, so identical passwords produce different hashes and precomputed rainbow tables are useless. The cost factor makes each hash deliberately slow, which hurts attackers trying billions of guesses far more than it hurts a single login. Fast hashes like MD5 or SHA-256 are the wrong tool for passwords.
Interview tip: A sharp follow-up: bcrypt only uses the first 72 bytes of the input, which matters for very long passphrases.
What is CORS and how do you enable it in Express?
CORS (Cross-Origin Resource Sharing) is a browser security rule: JavaScript on a page from one origin can read responses from an API on another origin only if that API's response headers allow it.
An origin is protocol plus domain plus port, so a React app on localhost:5173 calling an API on localhost:3000 is cross-origin because the ports differ. For non-simple requests, such as JSON POST requests or ones with an Authorization header, the browser first sends an OPTIONS preflight request and checks headers like Access-Control-Allow-Origin.
const cors = require('cors');
app.use(cors({
origin: process.env.ALLOWED_ORIGINS.split(','), // exact origins, not *
credentials: true // allow cookies
}));CORS is enforced only by browsers; Postman or curl ignore it, and it is not a server-side security wall. With credentials: true you cannot use the wildcard * origin.
Interview tip: Many candidates think CORS errors come from the server rejecting the request; the server usually responds fine and the browser blocks the page from reading it.
What are streams in Node.js and what types are there?
A stream processes data in small chunks as it arrives instead of loading everything into memory first, which keeps memory use low for large files and network data.
- Readable: a source, such as
fs.createReadStreamor an incoming HTTP request. - Writable: a destination, such as
fs.createWriteStreamor an HTTP response. - Duplex: both readable and writable, such as a TCP socket.
- Transform: a duplex stream that changes data as it passes, such as
zlib.createGzip().
const fs = require('fs');
const http = require('http');
http.createServer((req, res) => {
fs.createReadStream('video.mp4').pipe(res); // sends chunk by chunk
}).listen(3000);Reading a 2 GB file with readFile loads all of it into memory; streaming keeps only a small buffer in memory at a time.
What is a Buffer in Node.js?
A Buffer is a fixed-length sequence of raw bytes, used to handle binary data such as files, images, network packets and encoded text.
const buf = Buffer.from('नमस्ते', 'utf8');
console.log('नमस्ते'.length); // 6 (UTF-16 code units)
console.log(buf.length); // 18 (bytes in UTF-8)
console.log(Buffer.from('Hi').toString('hex')); // 4869
console.log(Buffer.from('Hi').toString('base64')); // SGk=Strings count characters, but buffers count bytes, which is why the Hindi word above is 6 long as a string and 18 bytes in UTF-8. Buffers are a subclass of Uint8Array, are allocated outside the V8 heap, and are what streams emit by default. Use Buffer.alloc(size) for a zero-filled buffer; Buffer.allocUnsafe is faster but may contain old memory contents.
What is EventEmitter in Node.js?
EventEmitter is the class from the events module that implements the publish-subscribe pattern: objects emit named events and listeners react to them. Streams, HTTP servers and sockets are all EventEmitters.
const EventEmitter = require('events');
class Orders extends EventEmitter {}
const orders = new Orders();
orders.on('placed', (id) => console.log('Send email for', id));
orders.once('placed', () => console.log('First order ever!'));
orders.emit('placed', 101);
orders.emit('placed', 102);
// Send email for 101 / First order ever! / Send email for 102Listeners run synchronously, in the order they were added, when emit is called. If an 'error' event is emitted with no listener, Node throws it and can crash the process, so always handle 'error'. Remove listeners you no longer need with off; Node warns when more than 10 listeners are added for one event, a common sign of a leak.
How do you handle errors in asynchronous Node.js code?
Use the error mechanism that matches the style: check err in callbacks, use .catch() on promises, and wrap await in try/catch.
async function getUser(id) {
try {
const [rows] = await pool.execute('SELECT * FROM users WHERE id = ?', [id]);
return rows[0];
} catch (err) {
console.error('DB error', err);
throw new Error('Could not load user', { cause: err });
}
}
// try/catch does NOT catch errors thrown later inside a callback:
try { setTimeout(() => { throw new Error('boom'); }, 0); } catch (e) { /* never runs */ }A try/catch only catches errors thrown while its own code runs, or awaited rejections. An error thrown later inside a timer or event callback escapes and becomes an uncaught exception. Also, forgetting await or return on a promise means its rejection is never caught, which leads to unhandled rejections.
How do you work with files using fs/promises?
The fs/promises module provides promise-based versions of the file system functions, so you can use async/await without blocking the event loop.
import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
import path from 'node:path';
const dir = path.join(import.meta.dirname, 'logs');
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, 'app.log'), 'started\n', { flag: 'a' });
const text = await readFile(path.join(dir, 'app.log'), 'utf8');
const files = await readdir(dir);Pass 'utf8' to get a string, otherwise you get a Buffer. Build paths with path.join rather than string concatenation so they work on Windows and Linux. The node: prefix makes it clear you mean a built-in module. For very large files, use streams instead of readFile, which loads the entire file into memory.
How do you connect Node.js to MySQL and why use a connection pool?
Use a driver such as mysql2 and create a connection pool, a set of open connections that requests borrow and return, instead of opening a new connection for every query.
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: 'shop',
connectionLimit: 10
});
const [rows] = await pool.execute('SELECT id, name FROM products WHERE price < ?', [500]);Opening a connection costs a network round trip and authentication, and one shared connection can run only one query at a time. A pool reuses connections, runs queries in parallel up to the limit, and queues extra requests. Create the pool once at startup and reuse it everywhere. For transactions, take one connection with pool.getConnection() and release it when done.
How do you prevent SQL injection in Node.js?
Never build SQL by joining strings with user input; use parameterised queries with placeholders, so the input is always treated as data and never as SQL.
// Vulnerable: email = "' OR '1'='1" returns every user
const sql = "SELECT * FROM users WHERE email = '" + req.body.email + "'";
// Safe: placeholder plus values array
const [rows] = await pool.execute(
'SELECT * FROM users WHERE email = ?',
[req.body.email]
);With mysql2, execute() sends a real prepared statement, and query() with ? placeholders escapes the values safely on the client. Placeholders work for values only, not table or column names; if a column name comes from the user, such as a sort field, check it against a whitelist. Also use a database user with only the permissions the app needs.
Interview tip: Interviewers may ask whether an ORM makes you safe automatically: only if you avoid its raw-query functions with concatenated input.
Node.js interview questions: advanced level
What happens with an unhandled promise rejection or uncaught exception in Node.js?
In current Node versions, both crash the process by default: an uncaught exception exits immediately, and an unhandled rejection is raised as an uncaught exception.
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason);
throw reason; // let it become an uncaught exception
});
process.on('uncaughtException', (err) => {
console.error('Uncaught exception:', err);
process.exit(1); // state may be corrupt; exit and let PM2 or Docker restart
});These handlers are a last line of defence for logging, not a way to keep running. After an uncaught exception the app may be in an unknown state, with half-finished operations or leaked connections, so the safe pattern is to log, try a quick graceful shutdown, exit with a non-zero code and let a process manager restart it. The real fix is to catch errors where they happen.
What is the cluster module and how does it help scale Node.js?
The cluster module starts several Node processes (workers) that share the same server port, so an app can use every CPU core instead of one.
const cluster = require('node:cluster');
const os = require('node:os');
const http = require('node:http');
if (cluster.isPrimary) {
for (let i = 0; i < os.availableParallelism(); i++) cluster.fork();
cluster.on('exit', () => cluster.fork()); // replace a crashed worker
} else {
http.createServer((req, res) => res.end('pid ' + process.pid)).listen(3000);
}The primary process only manages workers; incoming connections are spread across them. Each worker is a separate process with its own memory and event loop, so they share nothing. In-memory sessions, caches or rate-limit counters do not work across workers; keep that state in Redis or a database. In practice many teams use PM2 cluster mode or run several containers instead of writing this code by hand.
Interview tip: A likely follow-up: "your login works sometimes and fails sometimes after clustering" points to sessions stored in process memory.
What are worker threads and when should you use them?
worker_threads lets you run JavaScript on additional threads inside the same process, which is how you do CPU-heavy work without blocking the main event loop.
const { Worker, isMainThread, parentPort, workerData } = require('node:worker_threads');
if (isMainThread) {
const worker = new Worker(__filename, { workerData: 40 });
worker.on('message', (result) => console.log('fib =', result));
worker.on('error', console.error);
} else {
const fib = (n) => (n < 2 ? n : fib(n - 1) + fib(n - 2));
parentPort.postMessage(fib(workerData));
}Each worker has its own V8 instance and event loop; workers communicate by postMessage, can transfer ArrayBuffers, and can share memory through SharedArrayBuffer. Use them for image processing, hashing large data, parsing big files or heavy calculations. They do not help with I/O, which Node already does asynchronously. Cluster scales a server across processes; worker threads offload CPU tasks within one process. Reuse workers through a pool, because creating one is not free.
Which operations use the libuv thread pool, and why does its size matter?
Operations that the operating system cannot do asynchronously run on libuv's thread pool, which has 4 threads by default.
- Most
fsoperations, such asreadFileandstat. dns.lookup, which is whathttp.get('example.com')uses to resolve a hostname.- Async CPU-heavy
cryptofunctions such aspbkdf2,scryptandrandomBytes, and therefore libraries like bcrypt's native binding. - Async
zlibcompression.
Network I/O such as TCP sockets and HTTP servers does not use the pool. Because the pool is small and shared, five slow password hashes can delay unrelated file reads, since the fifth task queues until a thread frees up. You can raise the size with the UV_THREADPOOL_SIZE environment variable, set before the process starts, but more threads than CPU cores rarely helps CPU-bound work.
Interview tip: Mentioning that dns.lookup uses the pool, so DNS can slow down file I/O, shows real-world debugging experience.
How do you detect and avoid blocking the event loop?
The event loop is blocked whenever one piece of synchronous JavaScript runs for a long time; while it runs, no other request, timer or I/O callback can be handled.
- Common causes:
*SyncAPIs in request handlers,JSON.parseorJSON.stringifyon very large payloads, sorting or looping over huge arrays, sync crypto such asbcrypt.hashSync, and regular expressions with catastrophic backtracking on user input. - Detect it with
perf_hooks.monitorEventLoopDelay(), APM tools, or by profiling withnode --profor Chrome DevTools vianode --inspect. - Fix it by using async APIs, moving CPU work to worker threads or a job queue, streaming large data, paginating results, and splitting long loops into chunks scheduled with
setImmediate.
const { monitorEventLoopDelay } = require('node:perf_hooks');
const h = monitorEventLoopDelay();
h.enable();
setInterval(() => console.log('p99 delay ms:', h.percentile(99) / 1e6), 5000);
What is backpressure in streams and how does pipeline handle it?
Backpressure is what happens when a readable source produces data faster than the writable destination can consume it; handled correctly, the source pauses until the destination catches up.
writable.write() returns false when its internal buffer is full, and emits 'drain' when it is ready again. If you ignore that and keep writing, data piles up in memory. pipe() respects backpressure, but it does not forward errors or clean up the other streams when one fails. stream.pipeline does both:
const { pipeline } = require('node:stream/promises');
const fs = require('node:fs');
const zlib = require('node:zlib');
await pipeline(
fs.createReadStream('access.log'),
zlib.createGzip(),
fs.createWriteStream('access.log.gz')
);
console.log('Compressed');If any stage fails, the promise rejects and every stream is destroyed, so no file handles leak.
What security measures do you add to a Node.js and Express API?
Layer several basic protections; none of them alone is enough.
- helmet: sets security-related HTTP headers such as
Content-Security-Policy,Strict-Transport-SecurityandX-Content-Type-Options. - Rate limiting with
express-rate-limit, especially on login and OTP routes, to slow brute-force attacks. - Input validation with a schema library such as zod, joi or express-validator; reject anything unexpected.
- Parameterised queries, hashed passwords, secrets in environment variables, HTTPS everywhere.
- Body size limits, strict CORS origins, no stack traces in production error responses, and
npm auditfor vulnerable packages.
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
app.use(helmet());
app.use('/api/auth', rateLimit({ windowMs: 15 * 60 * 1000, limit: 10 }));Behind a reverse proxy, also set trust proxy so the rate limiter sees real client IPs.
How do you deploy a Node.js app in production with PM2 and a reverse proxy?
Run the app under a process manager like PM2 so it restarts on crashes and reboots, and put a reverse proxy such as Nginx in front of it to handle HTTPS and public traffic.
npm ci --omit=dev
pm2 start server.js --name api -i max # cluster mode, one process per core
pm2 save && pm2 startup # restore processes after a reboot
pm2 logs api
pm2 reload api # zero-downtime restartThe app listens on a local port such as 3000, and Nginx listens on 80 and 443, terminates TLS, serves static files, compresses responses and forwards API requests to 127.0.0.1:3000 with proxy_pass. That way Node never runs as root on a privileged port. Set NODE_ENV=production, and app.set('trust proxy', 1) in Express so req.ip and secure cookies work behind the proxy.
Interview tip: Being able to explain why you need both PM2 and Nginx, and what each one does, matters more than memorising commands.
How do you implement a graceful shutdown in a Node.js server?
On a termination signal, stop accepting new connections, let in-flight requests finish, close database pools and other resources, then exit; force-exit if it takes too long.
const server = app.listen(3000);
function shutdown(signal) {
console.log(signal, 'received, shutting down');
server.close(async () => {
await pool.end(); // close MySQL pool
process.exit(0);
});
setTimeout(() => process.exit(1), 10000).unref(); // hard deadline
}
process.on('SIGTERM', shutdown); // sent by Docker, Kubernetes, PM2
process.on('SIGINT', shutdown); // Ctrl+CWithout this, a deploy or restart kills requests mid-way, which can leave half-written data or give users errors. server.close() stops new connections and waits for open ones; the timer guarantees the process still exits if a connection never closes.
Where should you store a JWT on the client, and how do refresh tokens work?
Keep the access token short-lived and send long-lived refresh tokens only in an httpOnly, Secure, SameSite cookie, so JavaScript on the page cannot read them.
Tokens in localStorage can be stolen by any XSS bug, because any script on the page can read them. httpOnly cookies are hidden from scripts but are sent automatically, so you also need CSRF protection, which SameSite cookies largely provide.
- The access token lives for minutes and is sent with each API call.
- When it expires, the client calls
/auth/refresh; the server checks the refresh token against the database, issues a new pair and invalidates the old refresh token (rotation). - Logout or a password change deletes the stored refresh tokens, giving you the revocation that plain JWTs lack.
- Store refresh tokens hashed in the database, like passwords.
Interview tip: If a reused, already-rotated refresh token shows up, treat it as theft and revoke the whole token family for that user.