next function in the request-response cycle. They can execute code, modify req/res objects, end the request-response cycle, or pass control to the next middleware. Middleware is the backbone of Express applications, handling everything from body parsing to authentication to error handling — each function doing one job in a sequential pipeline.
const logger = (req, res, next) => {
console.log(`${req.method} ${req.url}`);
next(); // pass to next middleware
};
app.use(logger);
app.get('/', (req, res) => res.send('Home'));
Why it matters: Understanding the middleware model is fundamental to Express development. Every Express feature — routing, body parsing, static files, authentication — is implemented as middleware. This composability is what makes Express so flexible and powerful.
Real applications: A production Express API chains middleware in this order: cors → helmet → body-parser → rate-limiter → auth → routes → 404 → error-handler — each middleware handling exactly its responsibility before passing control forward.
Common mistakes: Forgetting to call next() in a middleware that doesn't send a response, which causes the request to hang and eventually time out. Also calling next() after sending a response, causing "Cannot set headers after they are sent" errors.
app via app.use() or app.METHOD()express.Router() for modular route groups(err, req, res, next) for error processingexpress.json(), express.static(), express.urlencoded()cors, helmet, morganWhy it matters: Categorizing middleware by type helps you reason about where and how to register it. Interviewers expect you to know the difference between application-level and router-level middleware, and specifically the four-parameter signature that makes error-handling middleware unique.
Real applications: A typical production API uses all five types together: built-in body parsers, Helmet for security headers (third-party), a custom auth checker (application-level), per-router auth (router-level), and a centralized 4-param handler for errors.
Common mistakes: Applying heavy middleware globally when it's only needed on a subset of routes — for example, attaching file upload parsing to all routes instead of just the file upload endpoints, adding unnecessary overhead to every request.
req, res, and next parameters for processing requests. You can modify the request object, validate incoming data, check authentication, or add custom logic before reaching the route handler. This pattern extracts cross-cutting concerns like logging, auth, and rate checking into reusable, independently testable middleware functions.
// Authentication middleware
const authenticate = (req, res, next) => {
const token = req.headers.authorization;
if (!token) return res.status(401).json({ error: 'No token' });
try {
req.user = verifyToken(token);
next();
} catch (err) {
res.status(403).json({ error: 'Invalid token' });
}
};
app.get('/profile', authenticate, (req, res) => {
res.json(req.user);
});
Why it matters: Writing custom middleware is where you move from just using Express to truly mastering it. Interviewers look for the ability to design clean, reusable middleware that handles edge cases, avoids leaking requests, and follows the single-responsibility principle.
Real applications: Authentication middleware is the most critical custom middleware in any API — it attaches the decoded user to req.user for downstream handlers to use, centralizing token verification in one place rather than repeating it in every route handler.
Common mistakes: Forgetting to handle the error path in custom middleware — if token verification throws, a missing catch block means the request hangs forever. Always ensure every code path either calls next(), next(err), or sends a response.
next() passes control to the next middleware in the stack, continuing the request-response cycle. If you don't call it and don't send a response, the request will hang indefinitely and eventually time out. You can also pass an error to next(error) to skip directly to the error-handling middleware, bypassing all regular middleware in between.
// Correct — calls next()
app.use((req, res, next) => {
req.requestTime = Date.now();
next();
});
// Ends the cycle — no next() needed
app.use((req, res, next) => {
res.send('Response sent');
});
// Skip to error handler
app.use((req, res, next) => {
next(new Error('Something broke'));
});
Why it matters: next() is the core mechanism of the Express middleware chain. Understanding when to call it, when to skip it, and when to pass an error determines whether your application works correctly or silently hangs on every edge case.
Real applications: In authentication middleware, calling next() grants access to the protected route, while calling next(new UnauthorizedError()) skips directly to the error handler that returns a 401 response — no if/else branching needed in the route handler.
Common mistakes: Calling next() after already calling res.json() or res.send() — this triggers the dreaded "Cannot set headers after they are sent" error in subsequent middleware. Once a response is sent, the cycle must end.
(err, req, res, next). Express recognizes this four-argument signature and routes errors to these handlers when next(err) is called. This centralized error handling prevents error logic from being scattered across every route — one handler processes all failures, formats them consistently, and decides what to log versus what to expose to clients.
// Trigger an error
app.get('/fail', (req, res, next) => {
next(new Error('Something went wrong'));
});
// Error handler — must have 4 params
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({
error: process.env.NODE_ENV === 'production' ? 'Internal error' : err.message
});
});
Why it matters: The 4-parameter signature is a strict contract — Express uses function.length to detect error handlers. Remove even one parameter and Express no longer recognizes it as an error handler, silently ignoring all errors passed to next(err).
Real applications: Production APIs use a single error handler that maps custom error classes to HTTP status codes: ValidationError → 400, AuthError → 401, NotFoundError → 404, everything else → 500, with Sentry logging for 500s only.
Common mistakes: Defining the error handler before routes — it only catches errors from handlers registered before it. Also not hiding stack traces in production; sending err.stack directly to API responses exposes internal file paths and logic to attackers.
app.use(), forming a sequential processing pipeline. The order determines which middleware processes the request first, and incorrect ordering causes hard-to-debug issues like missing req.body, bypassed authentication, or errors that never reach the error handler.
// 1. Parse body first
app.use(express.json());
// 2. Then log
app.use(morgan('dev'));
// 3. Then authenticate
app.use('/api', authMiddleware);
// 4. Then routes
app.use('/api/users', userRouter);
// 5. 404 handler (after all routes)
app.use((req, res) => res.status(404).json({ error: 'Not found' }));
// 6. Error handler last
app.use((err, req, res, next) => { ... });
Why it matters: Middleware order is one of the most common interview topics. Misplacing even one piece — like registering auth after routes, or the error handler before routes — causes the entire pipeline to fail silently in ways that are hard to trace without understanding the order rule.
Real applications: Observability tools like APM agents (New Relic, Datadog) must be registered first in the middleware chain to capture timing and context for all subsequent middleware — they wrap the entire pipeline and can't observe what ran before them.
Common mistakes: Registering express.json() after route handlers that need req.body, resulting in req.body being undefined mysteriously. This is the single most common Express beginner mistake and happens from not understanding sequential middleware execution.
app.use(). Using battle-tested packages for security, logging, and compression leads to production-ready implementations with far fewer edge cases than hand-rolling the same functionality.
const helmet = require('helmet');
const morgan = require('morgan');
app.use(helmet());
app.use(morgan('combined'));
Why it matters: Not knowing key third-party middleware is a flag in interviews. Particularly Helmet (security headers) is expected on any production Express app — skipping it leaves the application vulnerable to clickjacking, XSS, and MIME sniffing attacks with zero effort to fix.
Real applications: Helmet + compression + morgan + cors is the baseline middleware stack in virtually every production Express API. They collectively handle security headers, response compression, request logging, and cross-origin access in under 10 lines of code.
Common mistakes: Installing third-party middleware without reviewing its dependencies and update frequency. Outdated middleware packages can introduce security vulnerabilities — always check npm audit and keep dependencies updated with automated tools like Dependabot or Renovate.
app.use() with a path prefix to apply middleware to all routes under that path. Router-level middleware using router.use() scopes middleware to all routes within that router, giving you fine-grained control over which requests each middleware processes.
// Single route middleware
app.get('/admin', authenticate, adminOnly, (req, res) => {
res.json({ admin: true });
});
// Path-specific middleware
app.use('/api', rateLimiter);
// Router-level middleware
const router = express.Router();
router.use(authenticate);
router.get('/profile', getProfile);
app.use('/user', router);
Why it matters: Applying middleware at the wrong scope is a common performance and security issue. Global middleware on authentication for public routes adds unnecessary overhead, while missing route-specific middleware on protected routes creates security gaps.
Real applications: An API with public and private sections applies rate limiting globally, authentication to private routers only, and admin-role middleware to individual admin routes — creating a layered defense with minimal overhead on public endpoints.
Common mistakes: Applying computationally expensive middleware (like JWT verification) globally to all routes including public ones like health checks and static files, adding unnecessary latency to every request even when authentication isn't needed.
429 Too Many Requests status when the limit is exceeded. This is essential for public APIs and authentication endpoints which are common targets for brute-force and credential-stuffing attacks.
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: { error: 'Too many requests, try again later' },
standardHeaders: true,
legacyHeaders: false
});
app.use('/api', limiter);
Why it matters: Rate limiting is a baseline security control for any public API. Interviewers expect you to know about it, and omitting it from architecture discussions signals unawareness of common API security requirements and abuse vectors.
Real applications: Authentication endpoints like /login and /forgot-password use much stricter rate limits (e.g., 5 requests per minute) than general API endpoints, preventing brute-force attacks while keeping the app responsive for legitimate users.
Common mistakes: Using in-memory rate limiting with multiple server instances — each instance has its own counter, so a client can make max * numInstances requests before being blocked. Use Redis-backed storage (rate-limit-redis) to share state across all instances.
cors(options), morgan(format), and rateLimit(config).
function requestLogger(options = {}) {
const { format = 'short' } = options;
return (req, res, next) => {
if (format === 'short') {
console.log(`${req.method} ${req.url}`);
} else {
console.log(`${req.method} ${req.url} - ${req.ip}`);
}
next();
};
}
app.use(requestLogger({ format: 'detailed' }));
Why it matters: The factory pattern is how professional middleware is designed in Node.js. Interviewers expect you to recognize and implement it — it's both a JavaScript closures question and an Express architecture question wrapped in one.
Real applications: The validate(schema) factory pattern in request validation applies the same validation logic across dozens of routes with different schemas per route — app.post('/users', validate(userSchema), createUser) — cleanly and without duplication.
Common mistakes: Importing and calling middleware without the factory invocation — writing app.use(cors) instead of app.use(cors()) — passing the factory function reference instead of calling it to get the actual middleware, causing confusing type errors at runtime.
next() using try-catch or a wrapper utility — this is one of the most critical patterns for robust production Express applications.
// Problem: async errors are NOT caught by Express
app.get('/data', async (req, res) => {
const data = await fetchData(); // If this throws, Express won't catch it
res.json(data);
});
// Solution 1: try-catch
app.get('/data', async (req, res, next) => {
try {
const data = await fetchData();
res.json(data);
} catch (err) {
next(err);
}
});
// Solution 2: wrapper function
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/data', asyncHandler(async (req, res) => {
const data = await fetchData();
res.json(data);
}));
Why it matters: This is a high-frequency interview question because it combines async/await understanding with Express error handling. Unhandled async errors in Express 4 cause process crashes or hung requests — both are production-critical issues.
Real applications: The asyncHandler wrapper is used in virtually every production Express codebase — it eliminates repetitive try-catch blocks across all async routes and guarantees all errors funnel to the centralized error handler.
Common mistakes: Writing async (req, res) => without a try-catch and without wrapping in asyncHandler — in Express 4, this silently drops the error or crashes the process with an unhandled rejection. Express 5 fixes this natively, but Express 4 is still dominant.
app.use() matches any route that starts with the specified path (prefix match), while app.all() requires an exact path match for all HTTP methods. app.use() is primarily for mounting middleware and routers, whereas app.all() is a route handler for a specific endpoint that responds to every HTTP method. This subtle path-matching difference is important for correct middleware scoping.
// app.use() — matches /api, /api/users, /api/anything
app.use('/api', (req, res, next) => {
console.log('Matches any path starting with /api');
next();
});
// app.all() — matches ONLY /api exactly
app.all('/api', (req, res) => {
console.log('Only matches /api exactly');
res.send('API root');
});
// app.all with pattern — matches /api/users for any HTTP method
app.all('/api/users', (req, res) => {
res.json({ method: req.method });
});
Why it matters: Confusing these two is a common interview mistake. Using app.all('/api', auth) instead of app.use('/api', auth) would only authenticate requests for the exact path /api, leaving all /api/users, /api/products routes unprotected.
Real applications: CORS preflight requests use app.options('*', cors()) or app.all('*', cors()) patterns to handle OPTIONS requests globally before routing — something that requires exact vs prefix matching awareness to implement correctly.
Common mistakes: Using app.all('*', middleware) as a catch-all instead of app.use(middleware) — they're functionally similar but semantically different, and using app.all for middleware violates the intended purpose of each API.
const Joi = require('joi');
function validate(schema) {
return (req, res, next) => {
const { error } = schema.validate(req.body, { abortEarly: false });
if (error) {
return res.status(400).json({
errors: error.details.map(d => d.message)
});
}
next();
};
}
const userSchema = Joi.object({
name: Joi.string().min(2).required(),
email: Joi.string().email().required(),
age: Joi.number().min(18)
});
app.post('/users', validate(userSchema), createUser);
Why it matters: Server-side validation is a security requirement, not optional. Client validation can always be bypassed with tools like curl or Postman. Without server validation, malformed data reaches your database, causing corruption, query failures, or injection vulnerabilities.
Real applications: Registration and payment APIs use strict schema validation to ensure all required fields are present, emails are valid format, prices are positive numbers, and string fields have appropriate length limits — before any database operation begins.
Common mistakes: Only validating req.body and forgetting to validate req.params and req.query — a route expecting a numeric ID in req.params.id that receives a SQL injection string can cause vulnerabilities if not sanitized and validated.
res.on('finish') event that fires when the response is fully sent. Using process.hrtime.bigint() provides nanosecond precision for accurate benchmarking.
function responseTime(req, res, next) {
const start = process.hrtime.bigint();
res.on('finish', () => {
const end = process.hrtime.bigint();
const duration = Number(end - start) / 1e6; // Convert to ms
console.log(`${req.method} ${req.url} - ${duration.toFixed(2)}ms - ${res.statusCode}`);
});
next();
}
app.use(responseTime);
// Or use the response-time package
const responseTimeLib = require('response-time');
app.use(responseTimeLib((req, res, time) => {
res.setHeader('X-Response-Time', `${time.toFixed(2)}ms`);
}));
Why it matters: Response time data is foundational for performance optimization and SLA monitoring. Without it, performance regressions go undetected until users complain. Combining response times with endpoint labels lets you identify the exact slowest routes.
Real applications: Prometheus + Grafana is the standard observability stack in production Node.js services. A response time middleware emits timing metrics per route+method combination, enabling dashboards that alert when p99 latency exceeds business-defined thresholds.
Common mistakes: Using Date.now() for timing — it has millisecond precision and is affected by system clock adjustments (NTP). process.hrtime.bigint() is monotonic and nanosecond-precise, making it the correct choice for performance measurement.
// Skip middleware based on condition
function unless(middleware, ...paths) {
return (req, res, next) => {
if (paths.some(path => req.path.startsWith(path))) {
return next(); // Skip middleware
}
middleware(req, res, next);
};
}
// Apply auth to all routes EXCEPT /login and /register
app.use(unless(authenticate, '/login', '/register', '/public'));
// Environment-based middleware
if (process.env.NODE_ENV === 'development') {
app.use(morgan('dev'));
}
// Content-type based
app.use((req, res, next) => {
if (req.is('application/json')) {
express.json()(req, res, next);
} else {
next();
}
});
Why it matters: The unless pattern solves the real-world problem of needing global middleware (like auth) but needing to skip it for specific public routes. Interviewers ask this to see if you can solve common middleware organization challenges elegantly.
Real applications: Health check endpoints at /health and /metrics must respond without authentication for load balancer probes. The unless pattern allows global auth middleware with exceptions for these paths without restructuring the entire route organization.
Common mistakes: Using environment conditionals inside middleware functions rather than outside — checking if (process.env.NODE_ENV === 'development') on every request instead of at startup to conditionally register the middleware, wasting cycles on every request in production.