Express.js Interview Questions and Answers
Last updated:
Check out 30 of the most common Express.js interview questions, then take an AI-powered practice interview
Q1What is Express.js and why is it so widely used with Node.js?
BasicFundamentals
Answer
Express.js is a minimalist, unopinionated web framework for Node.js, first released in 2010 by TJ Holowaychuk. It sits as a thin layer on top of Node's built-in `http` module and provides three things that raw Node lacks: a routing system (matching URLs and HTTP methods to handler functions), a middleware pipeline (a chain of functions that process each request), and helper methods on the request/response objects (`res.json`, `res.redirect`, `req.params`, etc.). It does not bundle an ORM, template engine, or auth solution, the philosophy is that you pick your own.
That minimalism is exactly why it stuck: any pattern you want (REST, GraphQL, server-side rendering, WebSockets, microservices) can be built on top, and the middleware ecosystem on npm has millions of weekly downloads. In India, almost every MERN-stack tutorial and bootcamp project uses Express, so it's the single backend framework most freshers know. Express is now maintained by the OpenJS Foundation (not a single individual or company), which finally produced Express 5.0 in October 2024 after years in beta. Even with newer frameworks (Fastify, Hono, NestJS) gaining ground, Express's combination of stability, minimalism, and ecosystem maturity makes it the safe default for new Node.js services in 2026.
Key Points
- Thin layer over Node's `http` module
- Routing + middleware pipeline + req/res helpers
- Unopinionated, bring your own ORM, auth, templating
- Massive npm middleware ecosystem
Q2How do you create a basic Express server and define routes?
BasicRouting
Answer
Import express, call the factory to get an `app` instance, register routes for the HTTP methods you need (`app.get`, `app.post`, `app.put`, `app.delete`, `app.patch`), and start the listener with `app.listen(port)`. The route handler receives `req`, `res`, and optionally `next`. Use `res.send()`, `res.json()`, or `res.status().json()` to respond.
In modern Express (4.x and 5.x), you should also use `express.json()` middleware so JSON bodies are parsed automatically into `req.body`. A few conventions every Indian Node.js team follows: put `app.listen()` in a separate `server.js` so tests can import the `app` without binding a port, always set a `PORT` environment variable (default 3000 for dev, often 5000 for production), and log the port on startup so deployment scripts can confirm the server came up. For TypeScript projects, the `Request` and `Response` types come from `@types/express` and let you type custom `req.user` and `req.body` shapes safely.
The `app.all('*', handler)` pattern matches any HTTP method, useful for a catch-all 404 or universal middleware. Use `app.route('/users').get(getUsers).post(createUser)` to chain handlers for the same path with different methods, which keeps your route file tidy when one resource has several verbs.
import express from 'express';
const app = express();
app.use(express.json());
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.post('/users', (req, res) => {
const { name, email } = req.body;
res.status(201).json({ id: 42, name, email });
});
app.listen(3000, () => console.log('listening on 3000'));
Key Points
- `app.METHOD(path, handler)` defines routes
- `express.json()` parses JSON bodies
- Use `res.status().json()` for responses
Q3What is middleware in Express and how is it different from a route handler?
BasicMiddleware
Answer
Middleware is any function with the signature `(req, res, next)` that Express runs as part of the request pipeline. The pipeline is ordered, every incoming request flows through middleware in the order they were registered with `app.use()` or `app.METHOD()`. A middleware can: (1) modify `req`/`res` (e.g. attach `req.user` after auth), (2) end the request by calling `res.send()`, or (3) pass control to the next middleware by calling `next()`.
A route handler is technically just a special middleware tied to an HTTP method + path. The distinction is mostly conceptual: middleware tends to be cross-cutting (logging, auth, CORS), and route handlers tend to be endpoint-specific. Forgetting to call `next()` or `res.end()` is the #1 cause of hanging requests in Express, the client just waits until it times out.
Middleware can be: application-level (`app.use(fn)`, every request), router-level (`router.use(fn)`, every request to that router), route-level (`app.get('/x', fn1, fn2)`, only this route), or error-handling (4-arg signature, only when `next(err)` is called). The mental model: a request walks down a tree of middleware, and each node decides whether to continue down, branch sideways (to error middleware), or terminate by sending a response.
Key Points
- Signature: `(req, res, next)`
- Runs in registration order
- Either calls `next()` or ends the response
- Cross-cutting concerns live here (logging, auth, CORS)
Q4How do you handle path parameters and query strings in Express?
BasicRouting
Answer
Path parameters are declared in the route with a colon prefix (`/users/:id`) and accessed via `req.params`. Query string values come from `req.query`, automatically parsed from the URL. Both are always strings, if you need a number, you convert (`Number(req.params.id)` or `parseInt`).
Route parameters can also have regex constraints in Express using the syntax `/users/:id(\d+)`, which only matches numeric IDs and otherwise falls through to the next route. You can have multiple parameters in a single path (`/users/:userId/posts/:postId`), they all show up as keys on `req.params`. For arrays in query strings, Express's default `qs` parser handles `?tag=node&tag=express` (returns an array) and bracket notation like `?filter[role]=admin&filter[status]=active` (returns a nested object), which is configurable via `app.set('query parser', 'simple')` if you want stricter parsing.
Always validate before using these values in DB queries, they're attacker-controlled and the #1 source of NoSQL/SQL injection vulnerabilities in MERN apps. A common interview trick question: what's the difference between `req.params`, `req.query`, and `req.body`? They map to URL path segments, URL query string, and request body respectively, all are attacker-controlled, all are strings (or objects) by default, and all need validation. There's also `req.originalUrl` (full URL including mount path) and `req.baseUrl` (the prefix that mounted the router), useful when writing reusable middleware that needs to know its mount point.
app.get('/users/:id', (req, res) => {
const userId = Number(req.params.id);
const includeProfile = req.query.profile === 'true';
res.json({ userId, includeProfile });
});
// GET /users/42?profile=true → { userId: 42, includeProfile: true }
Q5What does `app.use()` do in Express?
BasicMiddleware
Answer
`app.use([path], middleware)` mounts middleware on the application. With no path, the middleware runs for every incoming request. With a path, it runs only for requests whose URL starts with that path.
This is the primary way to: install body parsers (`app.use(express.json())`), attach security headers (`app.use(helmet())`), mount routers (`app.use('/api/v1', apiRouter)`), and register error-handling middleware. Middleware order matters: if you register a logger BEFORE `express.json()`, it logs the raw body; if AFTER, `req.body` is already parsed. A common bug is registering `express.static('public')` AFTER an authentication middleware, that breaks public asset loading.
You can also pass multiple middleware functions: `app.use(authMiddleware, rateLimitMiddleware, handler)`, they run in left-to-right order, and any one can short-circuit by responding instead of calling `next()`. The path matching for `app.use()` is prefix-based: `app.use('/api', m)` matches `/api`, `/api/users`, `/api/v1/anything`, unlike route methods which do exact matching. There's also `router.use()` (same semantics but scoped to a router) and the difference matters when nesting: `app.use('/api', apiRouter)` runs `apiRouter`'s middleware only for `/api/*` URLs.
Be careful with global middleware that touches the response, for instance a 'wrap response in envelope' middleware will rewrite responses from every route, including health checks and static files. Scope it tightly to your API routes.
Key Points
- Mounts middleware globally or path-scoped
- Order of registration matters
- Path matching is prefix-based, not exact
- Multiple middleware can be passed at once
Q6What's the difference between `res.send()`, `res.json()`, and `res.end()`?
BasicResponses
Answer
`res.end()` is the low-level method inherited from Node's `http.ServerResponse`, it closes the response stream and sends whatever was buffered. It does not set Content-Type or serialize anything. `res.send()` is Express's flexible helper: pass it a string → sets `text/html`, pass an object → it serializes to JSON and sets `application/json`, pass a Buffer → sets `application/octet-stream`. `res.json()` always serializes to JSON and sets `Content-Type: application/json`, even for strings/numbers (`res.json("hello")` sends `"hello"` as JSON). For APIs, prefer `res.json()` explicitly, it's clearer and avoids ambiguity with `res.send()` when the data type changes. `res.end()` is mostly used inside `res.write()` streaming patterns or when you want to send a status-only response (`res.status(204).end()`).
A related landmine: calling `res.send()` or `res.json()` twice in the same request throws `Cannot set headers after they are sent`, usually caused by forgetting `return` after `res.json()` in a guard clause. Always `return res.json(...)` from early-exit branches. Bonus methods worth knowing: `res.sendFile(path)` streams a file with proper Content-Type, `res.redirect(url)` sends a 302 by default, `res.cookie(name, value, opts)` sets a cookie (use `httpOnly: true`, `secure: true`, `sameSite: 'strict'` for security), and `res.set(headers)` lets you add custom response headers before the body.
// Three ways to respond, pick based on intent
res.end(); // empty response, low-level
res.send({ ok: true }); // sends JSON (auto-detected)
res.json({ ok: true }); // sends JSON (explicit)
res.status(204).end(); // 204 No Content, no body
Q7How do you serve static files in Express?
BasicStatic Files
Answer
Use the built-in `express.static(directory, options)` middleware. It serves files from the given directory under the mount path (or root if no path given). Express handles `Content-Type` detection, range requests (for video streaming), and conditional GETs (304 Not Modified via ETag/Last-Modified).
In production, you should still front Express with nginx or a CDN, Node.js is slower at serving static files than a dedicated server, and Cloudflare/CloudFront caching offloads the work entirely. For development and small admin assets, `express.static` is plenty. Common options: `maxAge: '7d'` sets the Cache-Control header, `immutable: true` is useful for fingerprinted assets (e.g. `/static/app.abc123.js`) so browsers cache forever, and `index: false` disables the default `index.html` behavior.
A frequent production bug: mounting `express.static` AFTER auth middleware so all public assets require login, always mount static-file middleware early in the chain, before auth. Security note: NEVER use user input to construct file paths passed to `express.static` or `res.sendFile` without validation, path traversal attacks (`../../etc/passwd`) are still common. The built-in middleware blocks these, but custom file serving usually doesn't. Use `path.join` with a fixed root, then verify the resolved path starts with that root before serving.
app.use(express.static('public'));
// File at ./public/css/app.css served at GET /css/app.css
app.use('/cdn', express.static('public', { maxAge: '7d' }));
// Same files at /cdn/css/app.css with a 7-day Cache-Control header
Q8What is the Express Router and why would you use it?
BasicRouting
Answer
`express.Router()` is a mini-app that lets you group routes and middleware into a separate module. Each Router has its own middleware stack and route handlers, and you mount it on the main app with `app.use(prefix, router)`. This is essential for any non-trivial codebase: instead of dumping 200 routes into `index.js`, you split them by feature (`routes/users.js`, `routes/orders.js`, `routes/payments.js`), each exporting a Router.
You can also attach middleware that runs only for routes in a specific router, e.g. an `authMiddleware` on the `routes/admin.js` router protects every admin endpoint without repeating it on each route. Routers can be nested too: a `v1Router` can mount a `usersRouter` at `/users`, which lets you version the API cleanly (`/api/v1/users`, `/api/v2/users`). For very large teams, the common pattern is one Router per `controller`, with the route file just wiring routes to controller methods, keeping routes thin and business logic in service modules.
Routers also support `router.param('id', loadUserById)` which runs a middleware automatically whenever `:id` appears in any route on that router, handy for loading a resource once for all routes that need it. The `mergeParams: true` option on a child router lets it access path params from the parent (useful for nested resources like `/posts/:postId/comments/:commentId` where the comments router needs `postId`).
// routes/users.js
import { Router } from 'express';
const router = Router();
router.get('/', listUsers);
router.post('/', createUser);
router.get('/:id', getUser);
export default router;
// index.js
import usersRouter from './routes/users.js';
app.use('/api/v1/users', usersRouter);
Q9How do you read the request body in Express?
BasicRequests
Answer
Express 4.16+ ships with built-in body parsers: `express.json()` for JSON, `express.urlencoded({ extended: true })` for form-encoded data, and `express.raw()` / `express.text()` for raw buffers. Mount them with `app.use()` BEFORE your routes. After that, the parsed body is on `req.body`.
If you don't mount the parser, `req.body` is `undefined`, which is a very common 'why is my POST broken' bug for freshers. For multipart form data (file uploads), you need a separate library, `multer` is the de-facto standard. Always set a `limit` on body parsers (default is 100kb) to prevent memory-exhaustion DoS attacks.
A subtle bit: webhook handlers (Razorpay, Stripe, Slack) often need the RAW body (a Buffer) for signature verification, if you mount `express.json()` globally, the raw body is gone by the time the webhook handler runs. The fix is to mount the JSON parser AFTER the webhook routes, or use `express.raw({ type: 'application/json' })` on that specific endpoint with a `verify` callback that stashes the raw buffer on `req`. Note that in Express 5 you must explicitly mount body parsers, they're no longer included by default in some configurations.
The `extended: true` option on `urlencoded` uses the `qs` library to parse nested objects (`a[b]=c` → `{ a: { b: 'c' } }`) while `extended: false` uses `querystring` (flat keys only). Stick with `extended: true` for typical form posts unless you specifically need stricter parsing.
import express from 'express';
const app = express();
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
app.post('/api/items', (req, res) => {
// req.body is populated automatically
console.log(req.body);
res.status(201).json({ ok: true });
});
Q10What is CORS and how do you enable it in Express?
BasicCORS
Answer
CORS (Cross-Origin Resource Sharing) is a browser security mechanism: JS running on `https://app.goodspace.ai` cannot fetch `https://api.goodspace.ai` unless the API sends back the right `Access-Control-Allow-Origin` headers. Express doesn't set these by default, so cross-origin frontends get blocked. The `cors` npm package is the standard fix, mount it as middleware.
Critical rule for production: never use `origin: '*'` if you also send credentials (cookies, Authorization header), browsers explicitly forbid that combination. Instead, pass a function that validates the origin against an allowlist. Preflight requests (OPTIONS) are also handled automatically by the `cors` middleware.
Note: CORS only applies to browser-initiated requests. Server-to-server calls (from your Express backend to another API) are not subject to CORS at all, that's an open misconception in interviews. Mobile apps using their native HTTP libraries are also exempt.
So 'I keep getting CORS errors when I call my API from Postman' is impossible, Postman ignores CORS, meaning the user is actually testing in a browser without realizing it. Preflight requests can become a latency issue: every cross-origin POST/PUT/DELETE triggers an OPTIONS request before the real one. Set `maxAge: 86400` in the CORS config so the browser caches the preflight result for 24 hours, this saves a round trip on every subsequent request.
import cors from 'cors';
const allowedOrigins = ['https://app.goodspace.ai', 'http://localhost:3000'];
app.use(cors({
origin: (origin, cb) => {
if (!origin || allowedOrigins.includes(origin)) cb(null, true);
else cb(new Error('Not allowed by CORS'));
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
}));
Key Points
- Browser-enforced, not server-enforced
- Use `cors` package
- Never combine `origin: '*'` with `credentials: true`
Q11How does Express match routes when there's an overlap?
BasicRouting
Answer
Express matches routes in the order they were registered, top to bottom. The FIRST middleware/route whose path and method match wins, Express does not pick the 'most specific' one. This causes a classic bug: if you register `app.get('/users/:id', getUser)` BEFORE `app.get('/users/me', getMe)`, then `/users/me` matches `:id` (with `id = 'me'`) and never reaches the `me` handler.
The fix is to register specific routes BEFORE parameterized ones. Same logic applies for routers: if you `app.use('/api', apiRouter)` after `app.use('/api/admin', adminRouter)`, admin routes still resolve because Express stops at the first matching handler that ends the response. The matcher uses `path-to-regexp` under the hood, `:id?` makes a param optional (Express 4 only, Express 5 deprecates this in favor of explicit alternative routes), `:id(\d+)` adds a regex constraint, and `*` is a wildcard.
Method matters too: `app.get('/x')` doesn't match POST requests to `/x`, but `app.use('/x', ...)` (no method) matches all methods. Express 5 changed the wildcard syntax, bare `*` is deprecated in favor of `/*splat` (named splat parameter) so the match goes into `req.params.splat`. If you're migrating from 4 to 5, run `grep -r "'\*'" src/` to find all your wildcard routes and update them.
Q12What is the difference between `next()` and `next(err)`?
BasicMiddleware
Answer
Calling `next()` with no argument passes control to the next middleware in the chain, this is the happy path. Calling `next(err)` (with any truthy argument) tells Express to skip all remaining regular middleware and jump to the next ERROR-handling middleware (the one with four arguments: `(err, req, res, next)`). This is the fundamental error-handling mechanism in Express.
If you call `next(err)` and there's no error handler registered, Express falls back to a default handler that returns a generic 500 with the stack trace in development and a sanitized version in production. A common mistake: throwing synchronously inside an async function, that creates an unhandled promise rejection in Express 4, which won't be caught by your error handler. Express 5 fixes this by automatically catching async errors.
Another less-obvious form: `next('route')` (a string, not an Error) is a special signal that jumps to the next matching route, not the error handler, useful for conditional dispatching. So the three forms are: `next()` continue, `next('route')` skip this route, `next(anythingElse)` go to error handler. Don't call `next()` AFTER you've already sent a response (e.g. `res.json(); next();`), Express will try to invoke the next handler with no response left to send, often causing 'headers already sent' errors. Either respond OR call `next()`, never both.
app.get('/items', async (req, res, next) => {
try {
const items = await db.getItems();
res.json(items);
} catch (err) {
next(err); // jumps to error-handling middleware
}
});
// Error handler (4 args, this is the signature Express looks for)
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: 'Internal Server Error' });
});
Q13Why does Express error-handling middleware need exactly four arguments?
IntermediateError Handling
Answer
Express identifies error-handling middleware by counting the function's `length` property, a middleware with arity 4 (`err, req, res, next`) is registered as an error handler; arity 3 is a regular middleware. This is purely convention, but it's strict: if you accidentally write `(err, req, res) => {}` (forgetting `next`), Express treats it as a regular 3-arg middleware and passes `err` as `req`, breaking everything silently. Always include the fourth `next` argument even if you don't use it (most error handlers don't, since they're meant to be terminal).
Register the error handler AFTER all your routes, error middleware mounted before the routes won't see errors thrown later in the pipeline. You can have multiple error handlers; they form a chain, and you call `next(err)` to pass the error along to the next one. Real-world pattern: have a single 'normalize error' middleware that converts known error types (Mongoose ValidationError, JWT errors, custom HttpError class) into a consistent JSON shape `{ error: { code, message, details } }`, followed by a final 'send response' middleware that actually writes the response.
Splitting these makes it easy to add new error types without touching the response logic. Always check `res.headersSent` before responding, if a handler partially wrote a response then threw, you can't send headers again without crashing, so just call `next(err)` to let Node terminate the connection.
// ✅ Correct, 4 args, registered LAST
app.use((err, req, res, next) => {
console.error('[error]', err);
if (res.headersSent) return next(err);
res.status(err.status || 500).json({ error: err.message });
});
// ❌ Wrong, only 3 args, Express treats this as a normal middleware
app.use((err, req, res) => {
res.status(500).json({ error: err.message });
});
Key Points
- Arity 4 = error handler, arity 3 = normal middleware
- Always include the `next` arg
- Register error handler LAST, after all routes
- Check `res.headersSent` before responding
Q14How does Express 5 handle async errors differently from Express 4?
IntermediateAsync / Express 5
Answer
In Express 4 (the version most Indian production codebases still run on as of 2026), if you write `app.get('/items', async (req, res) => { throw new Error('boom') })`, the thrown error becomes an unhandled promise rejection, your error-handling middleware never sees it, and the request just hangs until the client times out. The standard workaround was wrapping every async handler with `try/catch + next(err)` or using a utility like `express-async-handler`. Express 5 (released October 2024) finally fixes this: any rejected promise returned from a middleware or route handler is automatically passed to the next error-handling middleware via `next(err)`.
This is the single biggest reason to migrate from Express 4 to 5. The migration is mostly seamless, but be aware: a few path-matching changes (regex characters, optional params via `?` are deprecated in favor of `{}`), removed deprecated methods (`res.json(status, body)` is gone, use `res.status(status).json(body)`), and `req.param()` is removed entirely (use `req.params/query/body` directly). Body parser is no longer included by default, you must explicitly `app.use(express.json())`.
If you're on Express 4 and don't want to migrate yet, install `express-async-errors`, it monkey-patches Express 4 to behave like Express 5 for async errors. Most teams still ship Express 4 because of internal middleware that hasn't been validated against 5, but new projects should start on 5.
// Express 5, async error handling 'just works'
app.get('/items/:id', async (req, res) => {
const item = await db.findItem(req.params.id); // if this rejects...
if (!item) throw new HttpError(404, 'Not found'); // or this throws...
res.json(item); // ...error middleware catches it
});
// Express 4 equivalent (required the wrapper)
const asyncH = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
app.get('/items/:id', asyncH(async (req, res) => { ... }));
Key Points
- Express 4: async rejections silently hang the request
- Express 5: rejections auto-forward to error middleware
- Migrate with `npm install express@5`
Q15How do you implement JWT-based authentication in Express?
IntermediateAuthentication
Answer
Standard pattern: a `/login` endpoint validates credentials and returns a signed JWT; protected routes use an authentication middleware that extracts the token from the `Authorization: Bearer <token>` header and verifies it. Use `jsonwebtoken` for signing/verifying. Store the secret in `process.env.JWT_SECRET` (never in code, at goodspace.ai we use Infisical for production secrets).
Issue short-lived access tokens (15-30 minutes) and longer-lived refresh tokens stored in an HttpOnly, Secure, SameSite=Strict cookie, this limits XSS exposure. On verify, the middleware decodes the JWT and attaches the payload (or user record) to `req.user` so downstream handlers can use it. A subtle gotcha: always check `req.headers.authorization` is a string AND starts with `'Bearer '`, clients sometimes send the raw token, which you should reject.
Use asymmetric signing (RS256 or ES256) when multiple services need to verify the token but only one issues it, the issuer holds the private key, verifiers only need the public key. For session-style auth where you want server-side revocation, JWTs are the wrong choice, use opaque session tokens stored in Redis instead. Common mistake: putting the entire user object in the JWT payload, keep it minimal (`sub`, `role`, `exp`) because every request carries the token, and large JWTs balloon header sizes.
Another classic: not setting `expiresIn` when signing, which creates tokens that never expire, a major security risk if any token leaks. Always set `expiresIn` and a sensible `audience`/`issuer` so a token from one app can't be used in another. For multi-device login, issue a unique `jti` (JWT ID) per token and keep a denylist in Redis for revoked tokens, checked on every request.
import jwt from 'jsonwebtoken';
const SECRET = process.env.JWT_SECRET;
function requireAuth(req, res, next) {
const header = req.headers.authorization || '';
if (!header.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
try {
req.user = jwt.verify(header.slice(7), SECRET);
next();
} catch (err) {
res.status(401).json({ error: 'Invalid token' });
}
}
app.post('/login', async (req, res) => {
const user = await verifyCredentials(req.body.email, req.body.password);
const token = jwt.sign({ sub: user.id, role: user.role }, SECRET, { expiresIn: '30m' });
res.json({ token });
});
app.get('/me', requireAuth, (req, res) => res.json(req.user));
Q16How do you secure an Express app for production?
IntermediateSecurity
Answer
Six layers, all mounted as middleware near the top of `index.js`: (1) `helmet()`, sets ~15 security HTTP headers (CSP, HSTS, X-Frame-Options, etc.). (2) `cors()` with an explicit origin allowlist, never `*` with credentials. (3) `express-rate-limit`, protect login, password-reset, and other expensive endpoints from brute-force attacks (e.g. 5 attempts per 15 min per IP). (4) `express.json({ limit: '1mb' })` and `express.urlencoded({ limit: '1mb' })`, cap body sizes to prevent memory DoS. (5) Input validation with `zod`, `joi`, or `express-validator`, never trust `req.body`, `req.query`, `req.params`. (6) `process.env` for all secrets, never check secrets into git (at goodspace.ai we use Infisical for production secret management). Beyond middleware: use HTTPS everywhere (terminate at nginx/load balancer), keep dependencies updated (`npm audit fix` in CI), sanitize MongoDB queries with `express-mongo-sanitize` to prevent NoSQL injection, and disable the `X-Powered-By` header (`app.disable('x-powered-by')`) so attackers can't trivially fingerprint Express. Auth-specific: store passwords with `bcrypt` at cost factor 12+ (or `argon2` for new apps), implement account lockout after N failed login attempts, send password-reset emails via a separate flow that doesn't reveal whether the email exists, and use HTTPOnly+Secure+SameSite cookies for refresh tokens. Final tip: run an OWASP ZAP or Burp scan against your staging environment before launch, it'll flag misconfigured CSP, missing security headers, and reflected XSS that you'd otherwise ship.
import helmet from 'helmet';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import mongoSanitize from 'express-mongo-sanitize';
app.disable('x-powered-by');
app.use(helmet());
app.use(cors({ origin: ['https://app.goodspace.ai'], credentials: true }));
app.use(express.json({ limit: '1mb' }));
app.use(mongoSanitize());
const loginLimiter = rateLimit({ windowMs: 15 * 60_000, max: 5, message: 'Too many login attempts' });
app.post('/login', loginLimiter, loginHandler);
Key Points
- helmet for security headers
- cors with allowlist (not *)
- rate-limit on expensive endpoints
- Validate every input
- Cap body size
Q17What is the full request/response lifecycle in Express?
IntermediateArchitecture
Answer
When a request hits Express, it flows through the middleware stack in this order: (1) Node's `http` server receives the raw request and creates `req`/`res` objects. (2) Express wraps these and starts running global middleware (`app.use()` items) in registration order. (3) Each middleware either calls `next()` to continue, ends the response (`res.send`, `res.json`, `res.end`), or calls `next(err)` to jump to error middleware. (4) Once the URL+method matches a route, Express runs any route-level middleware (the args before the final handler), then the handler. (5) The handler typically calls `res.json()` or similar, which triggers Express to write headers + body to the socket. (6) If `next()` is called from the last handler with no error and no response sent, Express's default 404 handler runs. (7) If `next(err)` is called anywhere, Express skips remaining handlers and looks for an error-handling middleware (4-arg signature) to run. The whole pipeline is synchronous-looking but asynchronous under the hood, handlers can `await` freely (especially in Express 5). Two events worth knowing: `res.on('finish')` fires when the response is fully sent (use this to record metrics after the response, not before), and `req.on('close')` fires if the client disconnects mid-request (useful for canceling expensive operations).
Understanding this flow is essential for debugging: 'hanging requests' mean some middleware forgot to call `next()` or send a response, 'duplicate responses' mean two middlewares both tried to respond, and 'errors not caught' means async errors escaped to Node's unhandled rejection handler (Express 4 problem, fixed in 5). One more subtlety worth understanding for interviews: Express uses a linear array of middleware/route entries internally, traversed via an index. `next()` increments the index and calls the next entry. `next('route')` jumps past entries of the same route. This is why route order matters and why error handlers (registered LAST) work, they're at the end of the array, only reached when `next(err)` is invoked.
Key Points
- Middleware runs in registration order
- Each middleware: next() | end response | next(err)
- Route handler runs after route-level middleware
- Default 404 if no handler responds
- Error middleware (4-arg) catches via next(err)
Q18How do you implement role-based access control (RBAC) in Express?
IntermediateAuthentication
Answer
After authentication populates `req.user` (e.g. from a JWT), add a second middleware factory that takes the required role(s) and returns a middleware function. Mount it on the routes that need protection. Keep RBAC checks at the route level, not inside the handler, this way the routing table itself documents authorization.
For more complex permissions (resource-level, e.g. 'user can edit only their own posts'), check ownership inside the handler after loading the resource. Avoid hard-coding role strings everywhere, define an enum/constant module so renaming a role is one change, not 50. For applications with complex permissions (think: enterprise SaaS with admin, manager, member, viewer roles, plus per-resource ACLs), pure role checks become a maintenance nightmare.
The mature pattern is policy-based access control: each protected action has a `policy` function that takes `(user, resource)` and returns boolean. Libraries like `CASL` for Node.js implement this cleanly. For most Indian B2C apps (Swiggy, Razorpay user-facing), simple role middleware is enough, save policies for B2B SaaS.
Critical security rule: always do authorization checks server-side. UI-level role gating (hiding buttons for non-admins) is a UX improvement, not a security control, an attacker can call the endpoint directly via curl. Every state-changing endpoint must verify authorization on every request, even if the client claims it's already done.
function requireRole(...allowed) {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: 'Unauthenticated' });
if (!allowed.includes(req.user.role)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
app.get('/admin/users', requireAuth, requireRole('admin'), listAllUsers);
app.delete('/posts/:id', requireAuth, requireRole('admin', 'moderator'), deletePost);
Q19How do you log Express requests in production?
IntermediateObservability
Answer
For dev, `morgan` is the standard, drop-in HTTP request logger with preset formats (`combined`, `dev`, `tiny`). For production, you want structured JSON logs so log aggregators (Datadog, ELK, SignOz) can index them. Use `pino-http` (built on Pino, very fast) or write a custom middleware that emits a JSON line per request with: method, path, status, duration, request ID, user ID, user agent, IP.
Generate a request ID (UUID) at the start of each request, attach it to `req.id` and the response header `X-Request-ID`, and include it in every log line, this lets you trace a single request across services. Never log request bodies wholesale (they contain passwords, tokens, PII), log only specific fields you actually need. For PII-heavy services, integrate with a logging library that supports redaction (Pino has `redact: ['password', 'creditCard']`).
Beyond per-request logs, you also want application logs (errors, warnings, business events), same logger instance, different namespace. At goodspace.ai we ship logs to SignOz via the OpenTelemetry collector, which gives us correlation between logs, traces, and metrics in one place. Pino is ~5x faster than Winston in throughput, which matters at 10k RPS, every microsecond per request adds up to seconds of CPU at scale.
Sample logs at high RPS: logging every single request is overkill and expensive. Sample at 1-10% of successful requests, 100% of errors, and 100% of slow requests (>500ms), you get the signal without the cost. Always pass through trace IDs from upstream services (e.g. `X-Cloud-Trace-Context`, `traceparent`) so distributed tracing tools can stitch the full request path.
import pino from 'pino';
import pinoHttp from 'pino-http';
import { randomUUID } from 'crypto';
const logger = pino({ redact: ['req.body.password', 'req.headers.authorization'] });
app.use((req, res, next) => {
req.id = req.headers['x-request-id'] || randomUUID();
res.setHeader('X-Request-ID', req.id);
next();
});
app.use(pinoHttp({ logger, genReqId: req => req.id }));
Q20What is `app.locals` vs `res.locals` in Express?
IntermediateArchitecture
Answer
`app.locals` is an object that lives for the lifetime of the Express app, use it for application-wide values you set at startup (config, feature flags, app version, database client). `res.locals` is a per-request object scoped to the response lifecycle, use it to pass data between middlewares for the same request. Common pattern: an auth middleware loads the user, sets `res.locals.user = user`, and view rendering middleware uses it. Both are also automatically made available in template engines (Pug, EJS) as template variables, useful for server-rendered apps.
For pure JSON APIs, `res.locals` is less common, most teams just attach to `req` (e.g. `req.user`). Pick one convention per codebase and stick with it. The distinction matters in TypeScript: `app.locals` and `res.locals` are typed as `Record<string, any>` by default, so add module augmentation in a `types/express.d.ts` to give them proper types.
Avoid using these as a substitute for proper dependency injection, they're easy to abuse and make code harder to test because everything implicitly depends on the request object. As a rule of thumb: `app.locals` for things that are truly app-global (config, feature-flag service), `res.locals` (or `req.x`) for per-request state, and module-level constants for everything else. Stuffing the entire service registry into `app.locals` makes refactoring painful because every consumer has to walk through `req.app.locals.something`.
// Startup
app.locals.appVersion = process.env.APP_VERSION;
// Per-request
app.use(async (req, res, next) => {
if (req.headers.authorization) {
res.locals.user = await loadUser(req.headers.authorization);
}
next();
});
app.get('/me', (req, res) => {
if (!res.locals.user) return res.status(401).end();
res.json({ user: res.locals.user, version: req.app.locals.appVersion });
});
Q21How do you handle file uploads in Express?
IntermediateRequests
Answer
Express doesn't parse `multipart/form-data` natively, use `multer`, the de-facto standard middleware. Configure it with a storage backend (disk for local, memory for stream-to-S3 pipelines) and field/file size limits. The middleware adds `req.file` (single file) or `req.files` (multiple) plus `req.body` for any non-file form fields.
ALWAYS validate the MIME type and file extension server-side (`req.file.mimetype` is attacker-controlled, verify by reading magic bytes with `file-type` for security-sensitive uploads). For production at scale, don't store files on the Express server's disk (single point of failure, breaks horizontal scaling), stream directly to S3/GCS using `multer-s3` or `@aws-sdk/lib-storage`. Set a sensible `limits.fileSize` (e.g. 5 MB for avatars, 50 MB for documents) to prevent disk-fill DoS attacks.
For very large files (videos, datasets), skip multer entirely and use presigned S3 URLs, the client uploads directly to S3 with a signed URL your API generates, bypassing the Express server completely. This is the pattern used by every video-upload app (YouTube-style) in 2026 and avoids the Express process holding large buffers in memory. Common security holes in upload handlers: trusting the filename (rename to a UUID before storing), accepting executable file types (block `.exe`, `.sh`, `.html`, `.svg` unless you know what you're doing, SVG can carry XSS), and reflecting the filename in the response (XSS vector if rendered to HTML). At goodspace.ai, uploads always get a fresh UUID filename and MIME type is verified from the actual bytes, not the Content-Type header.
import multer from 'multer';
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
fileFilter: (req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
cb(null, allowed.includes(file.mimetype));
},
});
app.post('/avatar', requireAuth, upload.single('avatar'), async (req, res) => {
const key = `avatars/${req.user.sub}/${Date.now()}.jpg`;
await s3.send(new PutObjectCommand({ Bucket: 'goodspace-uploads', Key: key, Body: req.file.buffer }));
res.json({ url: `https://cdn.goodspace.ai/${key}` });
});
Q22How do you write unit and integration tests for Express apps?
IntermediateTesting
Answer
Two layers: (1) **Unit tests** for pure functions (validators, business logic) using Jest, Vitest, or Mocha, no Express needed. (2) **Integration tests** that exercise the HTTP API using `supertest`. The key insight is to export your Express `app` object (without calling `.listen()`) from a separate file so tests can require it. `supertest` then makes requests against the app without a real port, it talks to the Express handlers directly, which is fast and parallelizable. Mock external services (DB, payment gateway) by replacing the modules with `jest.mock()` or by using dependency injection.
For DB tests specifically, the common patterns in India are: spin up a real PostgreSQL/MongoDB in Docker for CI (test-containers), or use SQLite/MongoDB-memory-server in-process for speed. For end-to-end tests of API contracts, tools like Pact (consumer-driven contracts) or simply running the real app against a real test DB give the most coverage. Pick test isolation strategy carefully: either reset the DB between every test (slow but bulletproof), wrap each test in a transaction that rolls back (fast but tricky with async code), or generate unique data per test (often the simplest).
Aim for 70-80% line coverage on the Express layer, 100% is rarely worth the maintenance cost for boilerplate routes. For middleware testing specifically, write tests against the middleware function directly: pass a fake `req`, `res`, and `next` and assert on the calls. Libraries like `node-mocks-http` make this easy. Authentication and authorization middleware especially benefit from direct unit testing because the failure modes (silently allowing access) are hard to catch in integration tests.
// app.js, export the app (no .listen here)
export default app;
// server.js, separate file that actually starts it
import app from './app.js';
app.listen(3000);
// app.test.js
import request from 'supertest';
import app from './app.js';
test('GET /health returns 200', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: 'ok' });
});
test('POST /users requires email', async () => {
const res = await request(app).post('/users').send({});
expect(res.status).toBe(400);
});
Q23How do you implement rate limiting in Express?
IntermediateRate Limiting
Answer
The standard library is `express-rate-limit`. Default behavior tracks counts in memory per IP, fine for a single-instance server, but useless if you have multiple Node processes (PM2 cluster) or multiple instances behind a load balancer, because each process has its own counter. For production, use a shared store: `rate-limit-redis` backs the counts in Redis so all instances share state.
Set tight limits on auth endpoints (5/15min for `/login`, 3/hour for `/password-reset`) and looser ones on read endpoints (60/min). Behind a load balancer, your code sees the LB's IP, not the user's. Set `app.set('trust proxy', 1)` and the LB's `X-Forwarded-For` is trusted; otherwise rate limits all hit the LB IP and break for every user.
Bonus pattern: rate-limit by user ID (not IP) for authenticated endpoints, IP-based limits are easily defeated with mobile carrier NAT or VPN rotation. Be careful with `trust proxy`, setting it to `true` (or a high number) can let attackers spoof their IP via the `X-Forwarded-For` header. Always set it to the exact number of proxies in front of your app (typically 1 for a single load balancer).
For very high-traffic public APIs (Razorpay's `/checkout` endpoint, for instance), rate limiting at the Express layer is too late, push it to the CDN (Cloudflare WAF rate-limiting rules) or the load balancer (nginx `limit_req`) so requests get rejected before hitting Node. For business-tier rate limits (e.g. 'free tier: 1000 req/day, paid tier: unlimited'), use the user's plan as the key and configure limits dynamically by looking up the plan from your DB before running the limiter, `express-rate-limit` supports a `keyGenerator` that can be async via custom middleware.
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const apiLimiter = rateLimit({
windowMs: 60_000, // 1 minute
max: 60,
store: new RedisStore({ sendCommand: (...args) => redis.call(...args) }),
keyGenerator: req => req.user?.sub || req.ip,
message: 'Too many requests, slow down',
});
app.set('trust proxy', 1); // we're behind nginx/ALB
app.use('/api', apiLimiter);
Q24What is `next('route')` and when would you use it?
IntermediateRouting
Answer
`next('route')` is a special form that tells Express to skip the remaining handlers in the CURRENT route's chain and try the next matching route. It only works inside route handlers that have multiple handler functions chained together (e.g. `app.get('/items/:id', handler1, handler2)`) or in `app.route()` patterns. Calling `next('route')` from `handler1` skips `handler2` and looks for the next `app.get('/items/:id', ...)` in the table.
Useful for A/B testing routes, feature-flag gating, or conditional dispatching where different handlers serve the same URL under different conditions. It's rarely needed, most teams use a regular `if` inside one handler, but it can produce cleaner code when the conditional logic is large. Note that `next('router')` (Express 4.11+) is the router-level equivalent: it bails out of the current router and continues in the parent.
Both are advanced and rarely seen in real codebases, but interviewers love asking about them because they reveal whether you actually understand the middleware chain or just know the happy path. A more practical use case: writing a feature-flag middleware that bypasses certain handlers when a flag is off. Without `next('route')` you'd have to copy the condition into every handler; with it, you separate the conditional gate from the implementation cleanly.
app.get('/items/:id',
(req, res, next) => {
if (req.user.role === 'admin') return next(); // admin path
return next('route'); // skip to next /items/:id route
},
(req, res) => res.json({ admin: true }) // admin handler
);
app.get('/items/:id', (req, res) => res.json({ admin: false })); // regular handler
Q25How do you handle 404 (not found) errors cleanly in Express?
IntermediateError Handling
Answer
Add a catch-all middleware AFTER all your routes but BEFORE the error-handling middleware. It runs only if no route matched. The cleanest pattern is to either return a JSON 404 directly or create an `HttpError` and pass it to `next(err)` so it flows through your central error handler (which is preferred for consistent logging and error shape).
In Express 5, the path matcher changed slightly, wildcards now use `/*splat` (named splat) instead of `*`, so if you're upgrading, update your 404 handler accordingly. For SPAs served by Express (e.g. a React app), you typically want unknown routes to serve `index.html` instead of returning 404, so the client-side router can handle them, register a wildcard handler that sends `index.html` AFTER your API routes. The pattern is: API routes first → API 404 handler → static file middleware → SPA fallback (`res.sendFile('index.html')`) → final error handler.
Mismatched order here is the cause of countless 'why is my React route returning JSON 404' Stack Overflow questions. For multi-tenant apps where unknown paths might be valid for some tenants but not others, the 404 handler can also do a database lookup before responding (e.g. check if the path matches a tenant-defined custom page), but be careful, this turns every 404 into a DB query and is a common DoS vector if not rate-limited.
// All routes registered first...
app.use('/api/v1/users', usersRouter);
app.use('/api/v1/orders', ordersRouter);
// Then 404 handler
app.use((req, res, next) => {
res.status(404).json({
error: 'Not Found',
path: req.originalUrl,
method: req.method,
});
});
// Then error handler
app.use((err, req, res, next) => {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
});
Q26How do you scale an Express app across multiple CPU cores using clustering or PM2?
AdvancedPerformance
Answer
Node.js is single-threaded, so a single Express process uses only one CPU core. To use the rest, you run multiple worker processes that share the same listening port. Two production-grade options: (1) **Node's built-in `cluster` module**: the primary forks one worker per CPU core; workers share the server socket via the primary.
Workable but you have to manually handle restart-on-crash, zero-downtime reloads, and log multiplexing. (2) **PM2** (`pm2 start app.js -i max`): the de-facto Indian Node.js production setup, it handles clustering, auto-restart on crash, log rotation, zero-downtime reloads (`pm2 reload`), and exposes process metrics. PM2 is what's running on goodspace.ai's backend VMs and most Razorpay/Swiggy-style Node services. Caveats with clustering: in-memory state (rate-limit counters, sessions, WebSocket connection lists) is per-worker, you MUST externalize state to Redis.
The alternative to clustering is Kubernetes with N pods each running a single-process Express; modern infra is moving in that direction because k8s gives you autoscaling and rolling deploys for free. Worker count: typically equals CPU count for I/O-bound APIs (most Express apps), but for CPU-heavy workloads (image processing, ML inference inside the handler, which you shouldn't do anyway), more workers don't help because they fight for CPU. Memory matters too: each worker has its own Node heap (~50-100 MB minimum), so 16 workers on a 4 GB VM is cutting it close.
Use `pm2 monit` to watch per-worker memory and `--max-memory-restart 512M` to auto-kill workers that leak. For zero-downtime deploys, `pm2 reload` restarts workers one at a time while the others serve traffic, combined with a `/health/ready` endpoint and proper graceful shutdown, you get true zero-downtime rolling deploys.
// ecosystem.config.js (PM2)
module.exports = {
apps: [{
name: 'goodspace-api',
script: './dist/server.js',
instances: 'max', // one worker per CPU core
exec_mode: 'cluster',
max_memory_restart: '512M',
env_production: { NODE_ENV: 'production', PORT: 5000 },
}],
};
// Deploy: pm2 start ecosystem.config.js --env production
// Zero-downtime reload: pm2 reload goodspace-api
Key Points
- Node is single-threaded, clustering uses all cores
- PM2 is the dominant production process manager in India
- Externalize all in-memory state to Redis when clustering
- Kubernetes is the modern alternative (one process per pod, scale pods)
Q27How do you profile and optimize a slow Express endpoint in production?
AdvancedPerformance
Answer
Production-grade workflow: (1) **Measure first**, add per-request timing middleware that logs duration to your log aggregator. Identify slow endpoints from the 95th/99th percentile, not the median. (2) **Split the timing**, instrument DB calls, external API calls, and CPU-bound work separately. OpenTelemetry with auto-instrumentation for Express + Mongoose + HTTP gives you flame graphs per request in SignOz/Datadog. (3) **Look at the top three causes**, in order: (a) **N+1 database queries**, loading items in a loop instead of one batched query.
Fix with `IN` queries, `populate` in Mongoose, joins in SQL. (b) **Blocking the event loop** with sync code, `JSON.parse` on a 10 MB string, sync crypto, `bcrypt.hashSync`. Move to async variants or worker threads. (c) **Missing indexes**, `EXPLAIN` your slow queries. (4) **Cache aggressively**, Redis with cache-aside for read-heavy endpoints, HTTP `Cache-Control` for public data. (5) **Profile with `clinic.js`** (`clinic doctor`, `clinic flame`) or `0x` for flame graphs, these tell you exactly which function is eating CPU. At extreme scale, switch JSON serialization from `JSON.stringify` to `fast-json-stringify` (with schemas) for 3-5x speedup on large responses, or move hot endpoints to Fastify which is ~2x faster than Express on the wire.
A few less-known wins: enable `keep-alive` on outgoing `http`/`https` agents (default is per-call connection setup, costs 10-50ms each), prefer `Buffer.from(string)` to `Buffer.alloc + write` for hot paths, and avoid `console.log` in production handlers, synchronous stdout writes block the event loop on Linux when stdout is a pipe (use Pino with async transport). For memory leaks, capture heap snapshots before and after load tests with `node --inspect` + Chrome DevTools; comparing snapshots reveals which objects are accumulating. Most leaks in Express are: untracked event listeners, global Map/cache with no eviction policy, or holding `req`/`res` references in closures past response end.
Key Points
- Measure first, instrument per-request and per-dependency
- N+1 queries are the #1 backend slowdown
- Never block the event loop with sync code
- Redis cache-aside for read-heavy endpoints
- clinic.js / 0x for flame graphs
Q28How would you architect an Express microservice for 10,000+ requests per second?
AdvancedArchitecture
Answer
10k RPS on Express is achievable in 2026 with the right setup. Stack: Node 22 LTS + Express 5 (for native async error handling) + PM2 cluster (or k8s pods) running 4-8 workers per instance, deployed across 4-8 instances behind a load balancer (nginx, AWS ALB, Cloudflare). Database is almost always the bottleneck: use PgBouncer for Postgres connection pooling (Node connection limits add up fast across workers), read replicas for read-heavy queries, and Redis cache-aside for the hot path.
Push everything CPU-bound out of the request path: image processing, PDF generation, ML inference, and bulk emails go to a queue (BullMQ + Redis is the standard Indian stack; alternatives are RabbitMQ or AWS SQS). Use HTTP `keep-alive` with a tuned `Agent` for outgoing calls, connection setup is the dominant cost when calling other microservices. Per-endpoint timeouts (e.g. via `connect-timeout` middleware) so a single slow downstream doesn't pile up requests.
Observability is non-negotiable: structured JSON logs, OpenTelemetry tracing, RED metrics (Rate, Errors, Duration) per endpoint. This is the shape most payments and consumer-scale Node teams in India converge on, and it is what interviewers at those companies probe for, the trick isn't the code, it's the operational discipline around it. Beyond raw throughput, focus on latency tail behavior: p99 latency is what users feel, not the median.
Common p99 killers: GC pauses (tune `--max-old-space-size` and prefer object pooling for hot paths), DB pool exhaustion (size > peak concurrency), and synchronous code paths inside async handlers (use `clinic doctor` to find them). Circuit breakers (`opossum`) protect downstream services from cascading failures, if your payment gateway is slow, the circuit breaker opens and fails fast instead of queuing requests in Express. For truly extreme scale (50k+ RPS), consider switching to Fastify or moving hot endpoints to a separate Rust/Go service, but profile first to confirm the framework is the bottleneck.
Key Points
- Cluster Express across cores AND instances
- PgBouncer + read replicas for the DB layer
- Cache-aside in Redis for the hot path
- Push CPU work to BullMQ workers
- Per-endpoint timeouts + structured tracing
Q29How do you implement graceful shutdown in an Express app?
AdvancedProduction
Answer
Graceful shutdown matters because every deploy, every rolling restart, and every pod replacement triggers a `SIGTERM`, without proper handling, in-flight requests get cut, DB transactions abort, and message queue jobs get lost. The pattern: (1) Listen for `SIGTERM` and `SIGINT`. (2) Stop accepting new connections, `server.close()` returns when in-flight requests finish (but doesn't wait forever). (3) Set a timeout (typically 30s), if requests aren't done by then, force exit so the orchestrator doesn't kill the process less politely. (4) Close DB pools, Redis connections, and queue workers AFTER the HTTP server is done. (5) Exit cleanly. In Kubernetes, your `terminationGracePeriodSeconds` should be longer than your in-app timeout (e.g. app=30s, k8s=35s) so k8s waits.
Also: implement a `/health/ready` endpoint that returns 503 once shutdown begins, load balancers see that and stop routing to this instance. Subtle pitfalls: `server.close()` only stops accepting new connections; existing keep-alive connections stay open until idle. Use `terminator-server` or implement your own keep-alive killer to forcibly close idle connections during shutdown.
For WebSockets, manually close each connection with a clean close code (1001) so clients know to reconnect to a fresh instance. For BullMQ workers, call `worker.close()` so the current job finishes but no new jobs are picked up. Test your shutdown logic by sending SIGTERM in staging while load-testing, you should see zero failed requests during the rolling restart. Most teams never test this and discover their 'zero-downtime deploys' actually drop 1-5% of requests every release.
const server = app.listen(PORT, () => console.log(`up on ${PORT}`));
let shuttingDown = false;
app.get('/health/ready', (req, res) => {
res.status(shuttingDown ? 503 : 200).end();
});
async function shutdown(signal) {
console.log(`[shutdown] received ${signal}`);
shuttingDown = true;
// Stop accepting new connections; wait for in-flight to finish
const forceExit = setTimeout(() => {
console.error('[shutdown] forcing exit after 30s');
process.exit(1);
}, 30_000).unref();
await new Promise(resolve => server.close(resolve));
await mongoose.connection.close();
await redis.quit();
await bullQueue.close();
clearTimeout(forceExit);
console.log('[shutdown] clean exit');
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Key Points
- Catch SIGTERM and SIGINT
- Stop accepting new requests, wait for in-flight
- Force exit after a timeout (≈30s)
- Set /health/ready to 503 during shutdown
- k8s terminationGracePeriodSeconds > app timeout
Q30When should you migrate from Express to NestJS, Fastify, or Hono?
AdvancedArchitecture
Answer
Express is the right default in 2026, but each alternative has a clear use case. **NestJS** is the right choice when your team is large (10+ backend engineers) and needs strong opinions: dependency injection, decorators, module boundaries, an Angular-like architecture. The discipline pays back at scale, and it's the direction Indian fintech and large-platform backend teams tend to drift in as headcount grows, either onto NestJS or onto an in-house convention layer over Express, which is why architecture interviews there often ask you to justify the trade-off. **Fastify** is the choice for raw throughput, it's ~2x faster than Express on benchmarks, has built-in JSON Schema validation (faster than express-validator), and a similar middleware model. Migration is straightforward if you don't use too many Express-specific middlewares.
Pick Fastify when you've already optimized your DB and the framework itself is the bottleneck. **Hono** is the newest contender (2023), designed for edge runtimes (Cloudflare Workers, Bun, Deno) with near-zero overhead. Use Hono when you want to deploy to the edge or you're running on Bun and want native speed. The migration cost for any of these is high, DON'T migrate just for performance unless you've profiled and know the framework is the bottleneck (it's almost never the framework; it's usually the database or a blocking call).
Express 5 + Pino + PM2 will take a typical Indian SaaS team to 10k RPS without breaking a sweat. A practical migration heuristic: if you're rewriting a service from scratch, evaluate Fastify or NestJS. If you have a working Express codebase under 50k LoC, stay on Express but upgrade to 5.
If your Express code is over 50k LoC, the migration cost almost always exceeds the benefit unless you have a specific scaling problem the framework can solve. Don't underestimate the operational cost of migration: new deploy configs, new monitoring, new bug patterns, retraining the team. The boring choice (Express) is usually the right one. Reserve framework migrations for green-field projects or for solving real, measured bottlenecks.
Key Points
- Stay on Express by default in 2026
- NestJS for large teams that need DI + module structure
- Fastify for raw throughput when framework is the bottleneck
- Hono for edge runtimes (Workers, Bun, Deno)
- Profile before migrating, DB is usually the bottleneck, not Express
Frequently Asked Questions
Is Express still relevant in 2026 with Fastify and Hono around?
Yes, Express still has the largest middleware ecosystem on npm, is the framework most Node.js developers learn first, and runs the vast majority of Indian production APIs in 2026, which is why fintech, marketplace, and developer-tooling employers keep it on the requirements list for backend roles. Fastify and Hono are faster on benchmarks, but the framework is almost never the bottleneck, your database is. Express 5's native async support closed the biggest historical gap. For most teams in India, Express is the safe default; consider alternatives only after profiling shows the framework is the limit. Even at companies that have migrated parts of their stack to NestJS or Fastify, Express still powers internal tools, admin dashboards, and legacy services, so knowing it deeply is non-negotiable for any Node.js backend role in 2026.
What does an Express.js / MERN developer earn in India?
₹6-20 LPA in 2026 for Node.js/Express backend developers, depending on experience. Freshers from bootcamps with strong MERN portfolios land at ₹4-7 LPA; mid-level (3-5 years) at ₹10-16 LPA; seniors (5+ years) at ₹15-25 LPA. Top-paying companies for Express engineers: Razorpay, Swiggy, Postman, Uber India, Flipkart, CRED. FAANG-equivalent compensation in India (Google, Microsoft) is higher but rarely uses Express specifically, they have internal stacks. Remote-first international companies pay 1.5-2x what Indian companies pay for the same skill set; a senior Node.js engineer at a US-headquartered company hiring from India typically lands ₹35-60 LPA.
Should I learn Express 4 or Express 5 in 2026?
Start with Express 5, the syntax is 99% the same as 4, and you avoid teaching yourself the async-error workaround patterns that Express 5 makes obsolete. However, when you join a company, expect to see Express 4 in their production codebase (most teams haven't migrated yet). Know both: build new projects on 5, but be ready to debug 4 in any role. Specifically know: how to write async error wrappers in 4, what changed with the path matcher in 5, and why `req.param()` was removed.
What's the best way to structure a large Express project?
Feature-based folder structure: `routes/`, `controllers/`, `services/`, `models/`, `middleware/`, `validators/`, with each feature (users, orders, payments) split across these layers. Use Express `Router()` per feature module and mount them on the main app. Keep route handlers thin, they should parse input, call a service function, and return the response. Business logic lives in services, not in route handlers. For very large codebases (100+ developers), consider NestJS, it forces this separation via decorators and modules. A pattern that works well in mid-size codebases (10-30 engineers): one feature folder per domain (`features/users/`), with the route, controller, service, validator, and tests co-located.
How is Express different from Next.js API routes?
Next.js API routes are file-based, framework-agnostic, and tied to Next.js's runtime, each file becomes a route. They're great for full-stack apps where the frontend and API ship together (the goodspace.ai webapp uses this pattern). Express is a standalone framework you run as a separate Node process, better for dedicated backend services, microservices, or cases where the API is consumed by multiple frontends (web, mobile, partners). In modern stacks, teams often use both: Next.js API routes for frontend-coupled endpoints, separate Express services for heavy backend work. Next.js API routes also support edge runtimes (Cloudflare Workers, Vercel Edge) for ultra-low-latency responses, something Express can't do natively because it depends on Node's `http` module.
Do I need to learn TypeScript for an Express job in India?
Increasingly, yes. As of 2026, roughly 70% of new Indian Node.js job postings list TypeScript as required or strongly preferred. Node.js openings at product companies like Razorpay, Swiggy, and Postman almost always ask for Express and TypeScript together, so treat them as one skill. Learn JS first (the runtime semantics matter), then layer TypeScript on top. The Express types (`@types/express`) are mature and well-documented, and tools like `ts-node` / `tsx` make the dev loop fast. Common TypeScript patterns with Express you should know: module augmentation to type `req.user`, generic `Request<Params, ResBody, ReqBody, ReqQuery>` for typed handlers, and using `zod` + `z.infer<>` to derive both runtime validation and TypeScript types from a single schema.
Introduction
Express.js remains the dominant Node.js web framework in 2026 and is the default backend layer for the MERN stack that powers nearly every Indian bootcamp curriculum. Despite the rise of NestJS, Fastify, and Hono, the majority of Node.js production APIs in India still run on Express, and that holds right across the hiring market, from fintech and consumer marketplaces to developer-tooling companies and services firms. The reasons are its minimalism, ecosystem maturity, and the millions of engineer-hours invested in middleware libraries. If you're applying for a backend role at any company hiring Node.js engineers today, Express is non-negotiable knowledge.
Express 5.0 finally shipped in October 2024 after nearly a decade in beta, bringing native async/await error handling, modern router internals, and breaking changes to a few common patterns. If you're interviewing in 2026, expect deep questions on the middleware pipeline, error-handling middleware (the famous four-argument signature), the request/response lifecycle, async error swallowing in Express 4 vs 5, route ordering pitfalls, and the security stack (helmet, cors, express-rate-limit, express-validator). Senior roles also test on production concerns: clustering with PM2, graceful shutdown, observability, and scaling Express services past 10k RPS.
This guide walks through the 30 most-asked Express.js interview questions in India for 2026, grouped by difficulty (12 basic, 13 intermediate, 5 advanced). Each answer focuses on the underlying mechanics, real-world gotchas you'll hit in production, and code examples taken from patterns used by companies hiring Node.js engineers today. We've prioritized the questions you'll actually be asked over textbook definitions, the goal is to leave you ready to discuss Express fluently in an interview, not to recite the docs.
Ready to practice Express.js interviews?
Don't just read, practice these Express.js questions live with an AI interviewer that asks follow-ups and scores your answers.